Гипотеза Gemini (нормаль из dv.vec) не подтвердилась: сырые байты LUMP_DISP_VERTS читаются как f32 с шагом 20 и дают осмысленные значения (vec=(0,0,1), dist=18.145). Формат разобран верно. Ориентация рельефа в порядке: 110 плоскостей вверх, 0 вниз, намотка не перевёрнута (0 из 11776). У спавна рельеф плоский — z от -151.4 до -148.0, поэтому «наклонены 104 вершины» это свойство карты, а не баг. Найдена ошибка в моей же сверке: справочник лампов читался со stride 48 вместо 16. Из-за этого LUMP_NODES выглядел навмешем, а лампы 6 и 18 менялись местами. Выводы части E14 про LUMP_NODES недействительны. «Белые плиты» разобраны: у материала color/white всего 9 граней, и это не ящик, а тонкие пластины 64x4 и 128x4 с нормалями ±X/±Y на четырёх высотах — то, что на глаз читается как «оси координат 3д». Большая белая поверхность в центре — другая: бетонная плита 2480x1936 над спавном, продублированная (9379==9385, 9380==9386), дубль есть в самой карте. Чёрные области проверены экспериментом: отключение тенев и отсечения задних граней меняет картину, значит чёрное приходит из лайтмапы, а не из теней. Подробности и следующие шаги в PARITY.md E15.
140 KiB
План Source RS: одиночная игра, совместимая с GMod
Статус: план согласован, начало исполнения (2026-10-12).
Каталог: ~/garrysmod-rs.
Связано: PLAN.md (общий план), PROBLEMS.md (боли), RECON.md (реверс).
Каждый этап — рабочий коммит и проверяемый результат, а не заготовка.
Главный принцип: совместимость, а не тождество внутренностей
Решение пользователя: «1 в 1 не надо прям, но надо совместимость».
Это меняет критерий готовности. Нас интересует не внутреннее устройство GMod, а то, что снаружи него видно и от чего зависит чужой код:
| Что должно работать | Почему это «совместимость» |
|---|---|
Контент карт и аддонов: BSP, VTF, VMT, MDL, VTX, а также lua/ с аддона |
аддон физически не запустится без этого |
Lua-API: Entity(), ents.*, Vector/Angle/Color, хуки, таймеры, file, surface, sound, render, cam, Curves |
код аддона должен выполняться без правок |
| Поведение игрока и физика | геймплей аддона завязан на это |
| Формат карт: сущности читаем так же, как в Source | иначе нельзя открыть чужую карту |
| Сетевой протокол (уже сделано) | мультиплеер |
Что не требуется: внутренние структуры движка, точное совпадение
побайтовых раскладок в нестандартных случаях, поддержка всего, что есть в
base_ents GMod.
Практическое следствие: там, где можно, сверяемся с исходниками Source.NET и
реальными файлами, а не угадываем; а публичные Lua-имена и сигнатуры берём из
настоящих lua/includes/ GMod, а не придумываем.
Порядок (подтверждён)
A → B → C → D → E, причём свет (C) раньше game.dll (E2) — так решил пользователь.
Часть A. Найденные дефекты (то, что сломано прямо сейчас)
Часть пунктов пользователь заметил визуально, часть подтверждена замером по
реальному gm_construct.bsp.
A1. Коллизия: скорость не гасится о поверхность
Симптом (пользователь): «подхожу в стену, отпускаю клавиши — входит внутрь чуть-чуть, потом выталкивает».
Причина (crates/engine/src/player.rs:432): move_aabb() возвращает только
(Aabb, on_ground) и не трогает self.velocity. Каждый кадр игрок
сдвигается на velocity * dt внутрь стены, получает выталкивание и оказывается
на грани — визуально «ныряет» и отскакивает. Горизонтальная скорость в стену
никогда не обнуляется.
Как в Source: CMoveData отдаёт clipNormal, скорость проектируется на
плоскость контакта: v -= n * min(0, dot(v, n)).
Исправление: move_aabb возвращает нормали контакта; Player::update гасит
компоненту скорости внутрь каждой нормали и обнуляет velocity.z при
on_ground. Плюс явное торможение при отпускании клавиш.
Приёмка: тест «в стену на полной скорости → после отпускания стоит
неподвижно, |velocity.xz| < 1, положение не дрейфует»; тест на отсутствие
просадки в стену при dt = 1/60 и скорости 250.
A2. Коллизия: твёрдость браша определяется неверно
Симптомы: «невозможно пройти в здания, хотя проход есть», «вода твёрдая».
Причина (player.rs:43): const TRIGGER: i32 = 0x4000_0000; и проверка
if contents == TRIGGER { continue }. То есть твёрдым считается всё, кроме
ровно 0x40000000 — включая браши с contents == 0 (по спецификации Source
CONTENTS_SOLID = 0x1, всё остальное не твёрдо) и браши воды/тумана.
Замер по gm_construct.bsp (2203 браша, лумп 18):
| contents | смысл | брашей | сейчас твёрдые | по спецификации |
|---|---|---|---|---|
0x08000001 |
SOLID | 1130 | да | да |
0x00000001 |
SOLID | 996 | да | да |
0x00000000 |
EMPTY (не твёрдый) | 70 | да ✗ | нет |
0x08000080 |
не SOLID | 3 | да ✗ | нет |
0x18000008 |
не SOLID | 2 | да ✗ | нет |
0x10000020 |
WATER | 1 | да ✗ | нет |
0x30000008 |
не SOLID | 1 | да ✗ | нет |
Итого 77 из 2203 брашей (3.5%) считаются твёрдыми ошибочно — невидимые стены в проходах и твёрдая вода озера.
Исправление: твёрдым считать браш, у которого установлен бит
CONTENTS_SOLID (0x1). Отдельно разобрать воду (0x20), туман (0x40),
окна (0x2) и решётки (0x8): они не останавливают игрока, но влияют на
жидкость и свет.
Приёмка: счётчик брашей по contents в логе; на gm_construct ожидается
2126 твёрдых и 77 не твёрдых; тесты «проход в здание открыт», «в воду можно
войти».
A3. Коллизия: нет broadphase для брашей
2203 браша проверяются целиком на каждом подшаге, в каждой из 4 итераций разрешения. Для displacement'ов есть сетка, для брашей — нет.
Исправление: 2D-сетка по XY с AABB каждого браша; запрос кандидатов по расширенному AABB хода.
Приёмка: замер времени кадра коллизии до/после; цель — стабильные 60 fps при обычном движении.
A4. Рендер: отсечения граней нет вообще
Симптом: «видно обратную сторону брашей».
Причина (crates/render/src/world_renderer.rs): cull_mode: None во всех
шести пайплайнах (строки 247, 292, 385, 483, 613).
Уточнение пользователя: в оригинале сквозь стены тоже видно — но там это намеренно (вид сквозь стену на HUD), а не отсутствие backface culling.
Исправление: CullMode::Back для мира и моделей; для двусторонней
геометрии — отдельный проход CullMode::Front с инверсией нормали (как в
Source: сначала фронт с инверсией, затем бэк).
Приёмка: в галерее gm_construct не видно изнанки стен.
A5. Рендер: деталь-текстуры не поддержаны → нет песка у озера
Симптом: «у озера трава должна переходить в песок, песка нет».
Причина: в crates/formats/src/bsp.rs перечислены 64 лумпа, и деталь-текстур
среди них нет. В Source 1 они лежат в GAME_LUMP как под-лумп
detailtextures.dll (LUMP_DETAILTEXTURES = 12) и накладываются поверх базовой
текстуры. Шейдер world.wgsl (38 строк) читает только base_texture и
умножает на вершинный light.
Исправление:
- Разбор
GAME_LUMP(запись лумпа 16 байт:fileofs, disksize, offset, fourCC), под-лумп 12 → список(текстура, scaleX, scaleY). - Проекция деталь-текстуры на мировые координаты по UV грани.
- Шейдер: дополнительные сэмплы со смешением,
$detailиз VMT.
Приёмка: у озера gm_construct виден переход травы в песок.
A6. Белая нетекстурированная поверхность (было: «белые панели на 0 0 0»)
Симптом по скриншоту: большая белая polygonal-поверхность, лежащая на
земле рядом с озером (камера на pos -1070 1565 533). Контур с прямыми
отрезками и острыми углами, поверхность явно повторяет рельеф местности.
Окружающие дома, трава, дороги и вода (слева, тёмно-синяя) выглядят нормально.
Это не пропы. Проп — это объёмная модель с чёткими гранями; здесь же
плоская поверхность, граница которой идёт по рельефу. Значит это браш- или
displacement-геометрия, у которой не разрешилась текстура — материал
рисуется fallback-белым (Material::fallback, world.rs:739).
Ранее я проверил пропы и ничего подозрительного не нашёл: пропов без origin
нет, с origin = "0 0 0" нет, наш код порождает всего 9 моделей
(5 × prop_dynamic lights_flescent01a, 4 × prop_physics мебели) — все с
корректными координатами. Это гипотеза отвергнута, версия снимка была неверной.
Кандидаты причины, по убыванию вероятности:
- Не найдена VTF-текстура для части поверхностей —
fallbackрисует белым. Особенно вероятно для displacement-поверхностей у воды: у них отдельныеtexinfo, которые не всегда есть в списке. - Не разрешён VMT — тогда материал падает в тот же белый fallback.
- Отдельная поверхность, которой полагается деталь-текстура, но базовая не загрузилась (связано с A5).
Действие (одна итерация, даёт точный ответ): дамп всех материалов мира —
{ имя VMT, имя VTF, загрузилась ли, причина отказа } плюс AABB каждого
батча. По скриншоту видно, что проблема локальная (один район карты), значит
список «отвалившихся» текстур будет небольшим и читаемым.
Побочный вывод: белый fallback — плохая диагностика и плохое поведение.
Нужна проверка в doctor (см. B4) и осмысленная заглушка: тёмно-шахматная
вместо белой, чтобы выделять проблему, а не маскировать её.
A8. Знаки и порядок осей при повороте моделей
rotate_model_vector строит базис forward/left/up, тогда как Source
использует forward/right/up. Модельная система координат MDL — правая
(X вперёд, Y влево, Z вверх), поэтому знак оси Y критичен: ошибка
разворачивает модель зеркально и ломает нормали.
Это не «на глаз» — сверяем с эталонным AngleVectors и закрываем unit-тестом
на известных углах (0 0 0, 0 90 0, 0 0 90, 90 0 0), фиксируя
ожидаемые векторы из формулы Source.
A7. Вода выглядит неправильно
Симптом: «просто какое-то отражение неба, никакой текстуры, твёрдая».
Причины:
- отражение — зашитая константа
vec3<f32>(0.64, 0.76, 0.90)вwater.wgsl:49, а не окружение; - одна поверхность с
discardпо знакуfacing, тогда как в Source их две ($abovewater1 и 0) — верх и изнанка; - нет преломления, поглощения по глубине, блика солнца, нормалей из текстуры,
$fogcolor-затухания; - твёрдость — из A2.
Исправление: две поверхности; отражение из окружения; преломление через цвет буфера; поглощение по расстоянию; specular от солнца; плюс A2.
Часть B. Фундамент: система энтити
Без неё не появляется ни свет, ни Lua над энтити, ни физика. Свет — это тоже
энтити (light_environment, light, light_spot).
B1. Ядро (crates/engine/src/entity.rs)
EntityIndex(u16)— слот +generationдля безопасных «умерших» указателей;Entity— classname, keyvalues, transform (origin/angles/local-origin), флаги (solid,movetype,model,render/rendergroup);EntityWorld— пул,spawn(kv),remove, итерация по classname, отложенное удаление (как в Source, чтобы не инвалидировать ссылки в кадре);- лимиты из файлов: максимум энтити, максимум ключей, максимум длины значения
(правило #1 AGENTS.md — никаких
unwrapна данных из файлов).
B2. Спавн из карты: build_world перестаёт вручную порождать модели — он
скармливает лумп ENTITIES в EntityWorld, а рендер и коллизия читают из неё.
Это убирает белые панели как класс проблем и даёт всем остальным этапам единый
источник истины.
B3. Коллизия по энтити: браши func_*/trigger_*/info_null с корректной
твёрдостью; AABB/OBB для моделей; trigger-зоны отдельным слоем.
B4. Отладка: ent_list / ent_info <classname> — иначе каждый следующий
дефект снова придётся ловить на глаз.
Приёмка: ent_list показывает все энтити gm_construct с корректными
координатами; коллизия func_* работает.
Часть C. Освещение (включая солнце)
Сейчас в шейдере один попиксельно интерполированный light: vec3 из вершин —
меньше, чем в Source даже на простых картах.
C1. Свет из карты: light, light_spot, light_dynamic (в gm_construct:
108 light + 28 light_spot), light_environment — это и есть солнце
(направление, цвет, интенсивность, флаг «небо»).
C2. Модель освещения в шейдере: динамический свет (солнце + до N источников в радиусе) поверх статического; источники ограничить по экрану и расстоянию.
C3. Sun-тени: каскадные shadow maps (в Source 3–4 каскада). Начать с одного каскада 2048².
C4. Lightmap: лумп LIGHTING/LIGHTING_HDR (12 МБ в gm_construct) —
попиксельно вместо вершинного; даёт качественный свет в помещениях.
C5. Материал: Phong-подобная модель — diffuse + specular + fresnel;
$phong/$specular/$color2 из VMT; самозатенение от карты нормалей.
C6. Небо и env_*: env_sky, env_fog, env_sun, env_tonemap,
env_cubemap, env_skybox.
Приёмка: на gm_construct видны солнечные тени от построек, помещения освещены не плоско, вода отражает окружение, есть спекуляр на металле.
Часть D. Lua поверх энтити
Текущий lua (1278 строк) — print, hook.*, timer.Simple,
concommand.*, заглушки. Ни типов, ни энтити.
D1. Типы: Vector, Angle, Color как userdata с метаметодами GMod
(арифметика, Angle:Forward(), Vector:Distance, :Angle).
D2. Entity(index) и методы: GetPos/SetPos, GetAngles/SetAngles,
GetClass/GetModel/SetModel, Remove, IsValid, PhysicsInit*.
D3. ents.*: Create, FindByClass, FindInSphere, GetAll, GetCount.
D4. Сетевые поля: SetNW*/GetNW* (в одиночке локальные, но с тем же API,
чтобы потом не переписывать аддоны).
D5. Библиотеки GMod: file.* (с песочницей по AGENTS.md), surface.*,
sound.*, render.*, cam.*, Curves, system.*, scrconcmd,
LocalPlayer. http — только после песочницы.
Приёмка: скрипт из гмода выполняется; список функций совместимости
(набранный из настоящих lua/includes/modules/) проходит.
Часть E. Одиночная игра как в GMod
E1. Player: ходьба по ступеням (step-up ~18 units), лестницы, приседание с
плавным переходом, плавание (Start Swim), ноклип, падение с уроном.
E2. game.dll на Lua: загрузка gamemodes/sandbox/gamemode/init.lua —
списки ents, weapons, NPCs, хуки Initialize, Think, PlayerSpawn.
Это то, что делает GMod «GMod’ом», а не просто картой Source.
E3. Оружие и предметы: weapon_*, item_* через Lua.
E4. Физика пропов: Jolt — prop_physics уже приходят с карты, но не падают
и не сталкиваются.
E5. NPC: начиная с базовых npc_*.
Порядок и разумная дистанция до GMod
GMod — это ~1 млн строк C++ плюс 15 лет аддон-экосистемы. Полное «1 в 1» недостижимо как финальное состояние, но достижимо как наращиваемое покрытие: A → B → C → D → E даёт к каждому моменту работающий одиночный клиент.
| Этап | Что даёт пользователю |
|---|---|
| A | «стены не пускают, вода не твёрдая, белая поверхность устранена, песок появился» |
| B | карта описывается объектами — база для всего |
| C | солнце, тени, нормальный свет |
| D | Lua-аддоны запускаются на нашей карте |
| E | одиночная игра, совместимая с аддонами |
Честное ограничение: «все энтити» — сотни классов. Но base_ents GMod — это
Lua-скрипты, а не C++. Когда появится исполнение lua/entities/ и
lua/weapons/, значительная часть этих классов заработает сама, из исходников
GMod. Поэтому наш код на этих этапах делает только то, чего в Lua нельзя:
низкий уровень графики, физика, загрузка ресурсов.
Свой набор «тяжёлых» классов держим минимальным: prop_physics, func_door,
func_button, trigger_*, light_*, env_*, info_*. Остальное — из Lua.
Решения, принятые пользователем
- Скриншот получен → A6 переформулирован: это не панели пропов, а нетекстурированная браш/displacement-поверхность. Версия про «панели в 0 0 0» отвергнута.
AngleVectors→ вопрос снят, работа делается как A8: сверка с эталонной формулой Source и фиксация unit-тестом, без опоры на память.- Приоритет → свет (C) раньше game.dll (E2).
Что осталось уточнить
Ничего блокирующего. Дополнительно уточню по ходу:
- белый fallback заменить на тёмно-шахматный (A6) — согласую при реализации, это меняет вид карты при недогруженных текстурах.
Часть C. Прогресс
C0. Источники света из карты (сделано)
engine::world::collect_lights() разбирает light_environment (солнце),
light, light_dynamic, light_fluoro_dynamic, light_spot. На gm_construct
находится 65 источников, из них 1 солнце и 22 прожектора.
Шейдер world.wgsl получил uniform-блок источников: солнце (до 4), точечные
и прожекторные (до 64, отбираются ближайшие к камере), плюс ambient.
Три ловушки, на которые напоролся (все проверены на gm_construct):
-
Четвёртая компонента
_lightозначает разные вещи. Уlightиlight_spotэто РАДИУС освещения (_light 255 255 255 300), а уlight_environment— множитель яркости солнца (_light 242 234 218 560). Если считать это яркостью везде, лампы дают вклад ×300 и карта выбивается в белый. -
Яркость нельзя домножать в цвет. Если умножить цвет на
scaleпри разборе и учестьscaleещё раз в шейдере, получается квадрат яркости. Цвет нормируется к единичному среднему, яркость хранится отдельным полемscale. -
Радиус 0 означал «светить во everywhere». Функция затухания при радиусе 0 возвращала 1.0 на любом расстоянии, и 22 прожектора складывались в сумму. Теперь у ламп без
_radiusподставляется 300. -
Лайтмапа уже содержит запечённое солнце. Это ключевой момент:
in.light— это и есть запечённый свет компилятора. Динамическое солнце сверху нужно только для направленной подсветки и будущих теней, поэтому его вклад намеренно мал (SUN_BRIGHTNESS_SCALE = 900даёт ~0.62 от карты). Наивная попытка добавить солнце «как есть» давала пересвет бетона в чистый белый.
Итоговые нормировки подобраны рендерами на gm_construct и лежат константами
SUN_BRIGHTNESS_SCALE, POINT_BRIGHTNESS_SCALE, AMBIENT рядом с кодом.
Осталось по части C
- Попиксельная лайтмапа (
LUMP_LIGHTING) вместо вершинного света. - Тени от солнца (каскадные shadow maps).
Phong-модель: specular + fresnel,$phong/$specularиз VMT.- Ambient из
_ambientкарты вместо константы. - Полный набор
env_*.
Часть B2. Физика: Rapier3D (сделано)
B2. Решение: мир не переезжает в Rapier
Rapier обслуживает только динамические тела. Геометрия карты остаётся
брашевой и разбирается нашим кодом (player.rs) — он проверен и быстрее на
тысячах брашей. Статика в Rapier добавляется явно и только из данных.
B2.1. Четыре дефекта, найденных измерением
Все четыре нашлись не «правдоподобным декодированием», а замером на карте. Порядок важен: каждый следующий дефект был бы невидим, пока не закрыт предыдущий.
-
Рельеф раздувался в 15 277 коллайдеров. Сетка по
LAND_CELL = 64дублировала каждый перекрытый треугольник в каждую ячейку: 89 треугольников давали 15 277 коллайдеров. Заменено одним объединённым trimesh с дедупликацией вершин по кванту 1/16 юнита: 11 776 треугольников → 6 148 вершин, 1 коллайдер. Точнее (никакого огрубления на швах ячеек) и на порядки дешевле. -
Уровень gm_construct построен брашами, а не displacement'ами. Рельеф покрывает лишь часть карты; игрок ходит по 2 126 брашам. Без брашей все пропы падали насквозь.
PhysicsWorld::add_brushesвосстанавливает вершины браша перебором троек плоскостей (Крамер): точка, подходящая под все плоскости — вершина выпуклой оболочки. У осевого браша 6 плоскостей = 20 троек. Замер: 2121 построен, 5 пропущено (вырожденные). -
Broad-phase Rapier наполняется только на
step(). После вставки геометрии луч её не видел: «под пропом пола нет» при 2 121 браше в мире. Лечится явной регистрациейbroad_phase.set_aabb()при вставке статики. -
setup_physicsстирал построенное. Начинался сself.physics = PhysicsWorld::new()и обнулял браши, добавленные строкой выше. Мир физики больше не пересоздаётся.
B2.2. Тест поймал баг, который замер бы не увидел
add_brushes создавал коллайдеры, но не увеличивал счётчик: лог показывал
static = 1, хотя брашей было 2 121. Тест box_brush_lands_where_the_box_is
упал на static_bodies() == 1 и указал прямо на строку.
B2.3. Подтверждение на gm_construct
Две независимые системы коллизий сверены друг с другом: брашевая (Collision)
и Rapier. Обе находят пол под пропами на z = −96 (Rapier −96.0,
брашевая −95.9). После 400 кадров все 4 prop_physics осели:
speed = 0.0, sleeping = true.
B2.4. Ограничения
- Коллайдер пропа — AABB модели, а не
vcollide. Source берёт физическую модель из MDL; мы её не разбираем. AABB всегда не меньше модели и всегда из данных, но для наклонённых пропов он «жирнее» настоящего. - Только
prop_physics*. Уprop_staticфизики в GMod нет. - FPS просел: 10–21 в headless-прогоне. Требуется замер отдельно с физикой и без — 2 121 выпуклый коллайдер может быть причиной.
B2.5. Что осталось по части B2
vcollideиз MDL вместо AABB.- Брашевые
func_door/func_buttonкак ограничивающие тела. env_explosionчерезapply_explosion(метод готов, вызов из Lua — нет).- Замер FPS с физикой и без, чтобы понять цену 2 121 коллайдера.
Часть D1. Lua-типы GMod (сделано)
D1. Что сделано
crates/lua/src/gmod.rs — Vector, Angle, Color как userdata с
арифметикой, полями на запись и tostring в формате GMod. Установлены в
глобалы вместе с math.Clamp.
Решения, важные для совместимости:
- Пропущенных аргументов не бывает.
Vector()иVector(1, 2)обязаны работать: так вызывается бытовой код Sandbox. Отсутствующее значение заменяется нулём, а не ошибкой. - Деление на ноль даёт 1, а не
inf.infв позиции пропа отравляет геометрию всего объекта и потом всплывает в отрисовке. - NaN из скрипта не доезжает до физики.
Color(0/0, ...)даёт белый,Vector(0/0, 1, 2)— вектор без NaN. - Компоненты цвета обрезаются в 0..255, а не отвергаются:
Color(1.5 * 300, 0, 0)— обычное дело в скриптах. - Source — левая система координат:
Angle(0,0,0):Forward()смотрит вдоль+X,Angle(0,90,0):Forward()— вдоль+Y, знак Z у forward отрицательный.
D1.1. Дефект чтения чисел в mlua 0.11
Value::as_number() в mlua 0.11 различает только Value::Number и молча
возвращает None на Value::Integer. LuaJIT отдаёт целые именно как
Integer, поэтому наивное чтение теряло все целочисленные аргументы:
Vector(1, 2, 3) --> Vector(0, 0, 0)
Выглядело это как «арифметика не работает», хотя дело было в чтении чисел.
Собственный value_number() принимает Number, Integer и строку (числом).
Тест на это обязателен: без него Vector() с целыми просто тихо отдаёт нули.
D1.2. Шум тригонометрии
cos(90°) в f32 — это 6.1e-17, а не ноль. Без обнуления
Angle(0, 90, 0):Forward() печатает -0.00000004371139 1 -0, и такие
значения копятся в преобразованиях координат. Порог — 1e-6.
D1.3. Особенности API mlua 0.11, на которые ушло время
- Для userdata нет автоматического
FromLua: чтение только черезAnyUserData::borrow::<T>().Lua::from_valueотсутствует, естьFromLua::from_lua(value, lua). - Поля —
add_fields(трейтUserDataFields), методы и метаметоды —add_methods(трейтUserDataMethods). Один трейт на всё не работает. add_meta_methodполучает(&Lua, &T, A): значение объекта идёт вторым аргументом, лишние аргументы Lua (__unm(a, a)) отбрасываются через().add_functionполучает объект какAnyUserDataпервым элементом кортежа.
D1.4. Что осталось по части D1
Entity()и объект сущности:GetPos/SetPos/GetClass/IsValid.ents.*:Create,GetAll,FindInSphere,GetPlayer.- Связка с движком:
gluaне должен зависеть отengine, обмен — через колбэки, которые регистрирует клиент. Vector:Angle(),Vector:Distanceмежду сущностями.
Часть D2. Мост Lua к движку (сделано)
D2.1. Устройство
glua не зависит от engine. Движок отдаёт поведение через трейт
glua::ents::Host, реализация которого живёт в клиенте
(crates/client/src/host.rs). Иначе получился бы цикл зависимостей и
Lua-слой нельзя было бы тестировать в одиночку.
Мир переведён в Rc<RefCell<World>>: скрипт обращается к нему из хуков в
любой момент, пока кадр держит заимствование. Все обращения идут через
try_borrow, потому что borrow при пересечении даёт panic от
RefCell, а падать из-за безобидного скрипта клиент не должен.
D2.2. Что доступно скрипту
Entity(n), ents.GetAll/GetByClass/FindInSphere/Create,
player, а также GetPos/SetPos/GetAngles/SetAngles/GetClass/GetModel/ GetVelocity/IsValid/Remove/GetName.
Entity(0) — игрок, Entity(9999) — невалидная сущность, а не ошибка:
скрипты routinely проверяют результат через IsValid().
D2.3. Найденный рассинхрон (важный)
move_prop обновлял позу экземпляра пропа, но не запись в пуле
сущностей. Проверено измерением: ents.GetByClass('prop_physics'):GetPos()
давал z = -64, пока физика уже опустила проп на z = -84. «Данные» и
«картинка» разошлись, и заметить это можно было только глазами.
Теперь поза пишется в оба места всегда, даже если геометрию пересчитать нечем (нет модели): проп без модели всё равно стоит правильно, он просто не рисуется. Возвращаемое значение означает именно «геометрия пересчитана».
Проверка после правки: Lua читает z = -84.15848, физика даёт
-84.15847778320313 — совпадение до последнего бита.
D2.4. Границы
SetModel,SetColor,PhysicsInitпринимаются и ничего не делают: принимать вызов нужно, чтобы скрипты не падали, но обещать нечего.GetVelocityделит скорость тела на шаг 1/60, а не читает «скорость за последний кадр»: иначе скрипт получил бы юниты/кадр вместо юнитов/с.- Созданная из Lua сущность не получает модель и тело физики.
- Игрок —
None, пока клиент его не зарегистрировал.
D2.5. Что осталось по части D2
Vector:Distance,Vector:Angle,GetModel-обратная привязка.Entity:GetTable/SetFieldи сетевые функции.- Регистрация сущности игрока, чтобы
Entity(0)иplayerсовпали. ents.Createдолжен уметь заводить тело физики дляprop_physics.- Подключение файлов
gamemodes/sandboxкак есть.
Часть D3. Стандартная библиотека GMod (сделано)
D3.1. Замер потребностей, а не догадка
Прогнаны 130 файлов gamemodes/sandbox, из них извлечены 182 различных вызова
API. Частоты по числу вхождений:
| вызов | вхождений | состояние |
|---|---|---|
hook.Run |
63 | сделано |
math.Clamp |
61 | сделано |
game.SinglePlayer |
36 | сделано |
math.Rand |
27 | сделано |
render.AddBeam |
21 | не сделано |
math.min / math.max |
17 / 5 | стандартная Lua |
net.* |
15+ | не сделано (сеть) |
render.* |
50+ | не сделано (рендер) |
file.* |
8+ | сделано с песочницей |
hook.Run и math.* — самые частые и самые дешёвые: они чисто Lua-слой.
render.* — это отдельная большая работа (лучи, спрайты, декали), она не в
этом инкременте.
D3.2. hook.Run не синоним hook.Call
hook.Run останавливается на первом хуке, вернувшем не-nil, и возвращает
его значение. На этом построены все проверки Sandbox (return false из хука
отменяет действие), поэтому подмена Run на Call молча ломала бы их.
D3.3. Дефект: ключи хуков — строки, а не числа
Отбор только целочисленных ключей давал пустой список: hook.Add пишет под
идентификатором скрипта ("GM:Think", "a"). Хуки не выполнялись вообще,
hook.Run возвращал nil, и это выглядело как «хуки не работают». Теперь
ключи сортируются как строка/число, порядок выполнения — по регистрации.
D3.4. Дефект mlua: Function::call теряет аргументы
Vec<Value> реализует IntoLua как таблицу, поэтому hook.call(args)
передавал хуку один аргумент вместо трёх — молча, без ошибки. Проверено
измерением: select("#", ...) внутри хука давал 1 при трёх переданных.
То же с одиночным числом: random.call::<f64>(10.0) вызывал math.random()
без аргументов.
Лечится через Variadic::from(vec![...]). Отдельный тест
hook_run_forwards_every_argument фиксирует именно эту ошибку — она
молчаливая, и без теста вернётся.
По этой же причине math.Rand определён на Lua, а не на Rust: он зовёт
math.random того же состояния и передаёт аргументы нативно.
D3.5. Песочница file.*
file.Read в GMod читает что угодно; мы запускаем чужие скрипты. Любой путь
разрешается относительно garrysmod/ и нормализуется; выход за корень —
отказ, а не предупреждение. Абсолютные пути скрипту не даны. Размер чтения
ограничен 8 МБ (правило #1 AGENTS.md).
Проверка на живом клиенте: file.Read("../../../../etc/passwd") вернул nil.
D3.6. Совпадения с GMod, зафиксированные тестами
math.Round(-2.5)= -3 (округление от нуля, какceil).math.Rand(x)=math.random(x)→ целое в [1, x], а не [0, x).math.Rand(a, b)включает верхнюю границу.math.Remapпри вырожденном диапазоне даётout_min, а неNaN.math.Approachне перескакивает цель.math.AngleDifference(10, 370)= 0: это одно направление.string.Explodeс пустым разделителем возвращаетnil, а не список.
D3.7. Что осталось по части D3
render.*: лучи, спрайты, декали, материалы,DrawWireframeBox.net.*: сообщения серверу.Cam,LocalPlayer,vgui/derma— для Q-меню.util.TraceLine,util.Compress,surface.*.
Часть B2.6. Констрейны ( Rapier joints)
Что сделано
crates/engine/src/constraints.rs — соединения между телами, соответствующие
классам GMod: phys_ballsocket, phys_hinge, phys_slider, phys_weld, rope.
Плюс add_force (база для thruster/hover) и set_angular_velocity_target
(мотор шарнира).
B2.6.1. Главная тонкость: якоря мировые, а Rapier ждёт локальные
Скрипт задаёт точку крепления в мировых координатах, Rapier — в
локальных каждого тела. Пересчёт делается через обратное вращение
(rotation().conjugate() * (world - translation)). Ошибка здесь не бросает
исключение: соединение просто «приклеивается» к другой точке и уезжает при
первом же движении.
B2.6.2. Что показали измерения, а не рассуждения
-
Констрейн не «стягивает» тела — и это правильно. Якорь в мировой точке по определению совпадает у обоих тел в момент создания, поэтому ограничение выполнено сразу и «включается» только когда тела расходятся. Первые пять тестов проверяли «расстояние между телами» и падали именно поэтому. Правильный инвариант — совпадение точек крепления под действием силы.
-
Сила в Rapier действует один шаг и сбрасывается после интегрирования:
add_forceнадо вызывать каждый кадр. Тест, подававший силу один раз, измерял не силу, а её отсутствие — и «падал» одинаково при 8000 и при 40 000. -
Спящее тело честно сообщает нулевую скорость. Проверять эффект силы по скорости бессмысленно, если тело уснуло на полу: корректный тест сравнивает прогон с силой и контрольный прогон без неё.
-
Ползун (
phys_slider) не удерживает. Сустав сообщаетenabled = trueи 5 заблокированных осей из 6, шар/шарнир/сварка в том же прогоне держат, но ползун пропускает расхождение по высоте при толчке вдоль оси. Тест помечен#[ignore]с причиной — зелёная галочка на неработающем коде хуже, чем явный отказ. Гипотеза к проверке: в 0.36 ползун требует согласованностиlocal_axis1/local_axis2иначе, чем задаёт ихnew(axis).
B2.6.3. Границы
- Констрейны соединяют только тела физики:
prop_physicsи объекты, созданные скриптом. Уprop_staticтела нет. - Оси соединения задаются в мировых координатах, как в GMod.
- Ограничение угла переводится в радианы; вырожденный диапазон игнорируется.
pulleyиthruster— это не суставы: тяга делается черезadd_force.
Часть B3. Проваливание сквозь пол: что проверено и что нет
B3.1. Две гипотезы, обе опровергнуты измерением
- «Пропов нет в коллизии игрока». Гипотеза была верной как диагноз,
но не как объяснение жалобы: игрок ходил по брашам. Пробел устранён
(
Collision::boxes), проп теперь стоит под ногами — это нужно в любом случае, но от проваливания по брашам не избавило. - «На низком FPS шаг подшага прошивает пол». Опровергнуто тестом
fast_fall_does_not_tunnel_thin_floor: при terminal-скорости 2000 юнитов/с и dt = 1/60 игрок садится на площадку толщиной 4 юнита иon_ground = true. Подшагов восемь хватает.
Обе версии выглядели правдоподобно и обе были неверны — характерная черта дефектов, которые видны только после попытки воспроизвести их тестом.
B3.2. Что измерено
- Стояние на спавне gm_construct: 180 кадров,
z = -143.9стабильно,on_ground = true, провала нет. - Значит дефект локальный и воспроизводится на определённой геометрии.
B3.3. Что нужно, чтобы найти причину
Точное место и обстоятельства: с какой высоты, после какого движения (прыжок, спуск по рампе, спуск с края) и на каком участке. Без этого пришлось бы перебирать геометрию карты наугад — а это ровно тот способ, которым эти две гипотезы и были порождены.
Часть B4. Физика движения: как в Source
B4.0. Корневая причина четырёх жалоб сразу
Симптомы, описанные как разные (проталкивает на расстояние, метания вдоль стены, сползание по рампам, bhop), оказались одной ошибкой:
self.velocity.z -= GRAVITY * dt; // выполнялось КАЖДЫЙ кадр
Гравитация применялась и на земле. Каждый кадр игрок вдавливался в геометрию, а разрешение столкновений выталкивало его обратно. Отсюда всё: «выталкивает на определённое расстояние» — это выталкивание из вдавливания; «то за стену, то обратно» — борьба позиции с выталкиванием; «сползает по рампе» — вертикаль всё время возвращается вниз.
Source делает наоборот: при FL_ONGROUND вертикальная скорость
обнуляется, а ApplyGravity в земле не вызывается вовсе.
B4.1. Что изменено
- Гравитация только в воздухе. На земле
velocity.z = 0. - Трение всегда на земле, а не только при отсутствии ввода: Source
вызывает
Friction()каждый кадр на земле — именно оно держит игрока на рампе. Константыsv_friction = 4,sv_stopspeed = 100. - Прыжок по фронту нажатия. Защёлка
jump_latchedдержится, пока пробел зажат; без неё удержание давало бесконечный bhop. - Подшаг
PM_StepSlideMove: полное движение, при блокировке — повтор с подъёмом наSTEP_HEIGHT = 18, затем спуск. Принимается только если спуск встал на опору, иначе это залезание на стену. При падении подшаг отключён — иначе в прыжке игрёт в потолок. MoveResult::blocked— признак упёрся в геометрию, нужен подшагу.
B4.2. Ошибки в моих же правках, пойманные тестами
jump_latched = jump— защёлка вставала на первом же кадре, ещё до первого прыжка, и прыгнуть было нельзя вообще.jump_latched = jump && self.on_ground— флаг сбрасывался тем же кадром, когда прыжок уже отработал (on_groundсброшен выше), bhop возвращался.- Тест на рамку мерил первоначальное падение (40 → 6), а не сползание, и падал с «сносом 34». После отделения приземления снос — 0.
B4.3. Тесты
standing_on_a_slope_does_not_slide_down— снос после приземления.holding_jump_does_not_bunnyhop— ровно один прыжок за 300 кадров.player_steps_up_a_curb— переступает бордюр 12 юнитов.player_stands_on_a_dynamic_body— стоит на пропе.
Часть B5. Осталось по физике движения
- Воздушная мобильность.
AIR_ACCEL = 12приGROUND_ACCEL = 10— в воздухе быстрее, чем на земле. В Source ускорение общее (sv_accelerate = 10), а в воздухеsv_air_max_wishspeed = 30ограничивает прирост, и трения в воздухе нет. Нужен Quake-алгоритмAccelerate. - Выталкивание из стен. Сейчас при упоре в стену игрока выталкивает; в Source движение просто останавливается.
sv_maxspeed = 320,sv_maxvelocity = 350как отдельные ограничения.- Ускорение/торможение поворота камеры, инерция при стрейфе.
Часть B6. Изучение CGameMovement (Source SDK 2013)
Источник: src/game/shared/gamemovement.cpp из ValveSoftware/source-sdk-2013
(доки Valve под Anubis, читался исходник). Ниже — расхождения нашей реализации
с оригиналом, найденные чтением первоисточника, а не догадками.
B6.1. Почему теряется скорость на рампе вверх
CGameMovement::StepMove устроен принципиально иначе, чем наш подшаг:
// Try sliding forward both on ground and up 16 pixels
// take the move that goes farthest
TryPlayerMove( &vecEndPos, &trace ); // сначала ВНИЗ — получаем vecDownPos/vecDownVel
...подъём на flStepSize...
TryPlayerMove(); // потом ВВЕРХ — получаем vecUpPos
// decide which one went farther
float flDownDist = ( vecDownPos.x - vecPos.x )^2 + ( vecDownPos.y - vecPos.y )^2;
float flUpDist = ( vecUpPos.x - vecPos.x )^2 + ( vecUpPos.y - vecPos.y )^2;
if ( flDownDist > flUpDist ) { едем вниз; }
else { /* copy z value from slide move */ mv->m_vecVelocity.z = vecDownVel.z; }
Три отличия от нашего кода, каждое даёт симптом:
- Пробуются оба варианта, побеждает тот, что ушёл дальше по горизонтали. Мы всегда идём путём «поднять на 18 и уронить». На рампе вверх путь вниз (без подшага) уходит дальше — значит оригинал его и берёт, а подшаг не выполняется вовсе. У нас он выполняется всегда и режет путь.
- Вертикальная скорость берётся из движения ВНИЗ
(
m_vecVelocity.z = vecDownVel.z), а не из движения вверх. Иначе на рампе появляется паразитная вертикальная составляющая. - Отказ от подшага при
trace.plane.normal[2] < 0.7— то есть на крутых поверхностях подшаг не применяется вовсе.
B6.2. Почему при спуске «прыгает с рампы»
Наш подшаг выполняется по признаку «движение заблокировано» и всегда с
подъёмом на STEP_HEIGHT = 18. На рампе вниз игрок кадр за кадром
подскакивает на 18 юнитов и падает обратно — визуально это «прыжки с
рампы». Оригинал при спуске идёт по пути flDownDist, который дальше, и
подшага не делает.
B6.3. Трение: у нас не так
CGameMovement::Friction() вызывается каждый кадр на земле безусловно
(проверка только GetGroundEntity() != NULL), а не «если нет ввода».
Формула оригинала:
control = (speed < sv_stopspeed) ? sv_stopspeed : speed; // 100 по умолчанию
drop += control * friction * frametime; // sv_friction = 4
newspeed = max(0, speed - drop);
У нас трение срабатывает только при wish.length_squared() < 1.0, то есть
при беге по рампе его нет вовсе.
B6.4. Гравитация делится пополам до и после движения
void CGameMovement::StartGravity( void ) {
mv->m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * 0.5 * frametime;
}
void CGameMovement::FinishGravity( void ) {
mv->m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * frametime * 0.5;
}
В комментарии Valve: «yes, this 0.5 looks wrong, but it's not». Суммарно гравитация та же, но момент приложения другой — это и даёт правильное ощущение веса на дуге.
B6.5. Ускорение: Quake-алгоритм, а не линейная интерполяция
currentspeed = velocity.Dot(wishdir);
addspeed = wishspeed - currentspeed;
if (addspeed <= 0) return;
accelspeed = accel * frametime * wishspeed * friction; // НЕ accel*dt!
if (accelspeed > addspeed) accelspeed = addspeed;
velocity += accelspeed * wishdir;
AirAccelerate отличается одной строкой — ограничением желаемой скорости:
if ( wishspd > GetAirSpeedCap() ) wishspd = GetAirSpeedCap(); // sv_air_max_wishspeed = 30
Вот почему в воздухе управление «вязкое»: прирост скорости за кадр
ограничен, а ускорение не накапливается. У нас AIR_ACCEL = 12 при
GROUND_ACCEL = 10 и линейная интерполяция к целевой скорости — в воздухе
у нас быстрее, чем на земле.
B6.6. Прыжок по фронту
В чистом Source IN_JUMP проверяется по уровню, но при выходе из воды
ставится mv->m_nOldButtons |= IN_JUMP; // Don't jump again until released.
То есть «не прыгать, пока не отпустят» — штатный приём движка, а не
изобретение: наша защёлка jump_latched воспроизводит именно его.
B6.7. Порядок работы на будущее
StepMoveпо оригиналу: оба варианта, побеждает дальний,zиз вниз. Чинит и потерю скорости, и «прыжки с рампы».Friction()безусловно на земле.StartGravity/FinishGravityпополам.Accelerate/AirAccelerateквото-формулой сsv_air_max_wishspeed.CheckVelocity— ограничивательsv_maxvelocity = 350.
Часть B7. Переписано движение по CGameMovement: коллизия тела
Главный дефект батча 2 был не в кинематике, а в самой проверке пересечения AABB с кирпичом. Её переписали; кинематику трогали только по оригиналу.
B7.1. Глубина проникновения считалась не по той грани
Формула была pen = dist - project_min(aabb, n) — «насколько тело зашло за
грань с внутренней стороны». Для игрока, стоящего на полу (низ на 1 юнит ниже
верха кирпича высотой 100), она даёт 172 юнита проникновения в нижнюю
грань — на 172 юнита выше неё. Тело выталкивало вниз, сквозь пол.
Отсюда все три жалобы сразу:
| Жалоба | Причина |
|---|---|
| «поверхность превратилась в лёд» | тело улетало вниз, on_ground становился ложным, трение не применялось |
| «выталкивает сквозь пол» | то же, плюс игрок проваливался на сотни юнитов |
| «выталкивает сквозь стены» | см. B7.2 |
Заменено на SAT по разделяющим осям: длина перекрытия интервалов проекций,
min(a1, b1) - max(a0, b0). Для того же игрока — 1 юнит, выход вверх.
Чтобы у кирпича был второй конец интервала, пришлось найти его вершины
(пересечения пар граней) и хранить проекцию противоположного края — plane_min.
B7.2. Минимальный вектор выхода не годится для тонкой стены
Даже с правильной глубиной SAT выбирает выход по меньшему вектору. У стены
шириной 16 при теле шириной 32 выход вперёд всегда короче выхода назад: тело,
дошедшее до середины стены, выталкивается сквозь неё. Замерено ровно до
x = 132.1 при стене на x = 100.
Поэтому добавлена swept-трассировка (clip_fraction) — тело останавливается
у грани и никогда не оказывается по другую сторону тонкой стены. Грань
«раздувается» на полугабариты тела; для плоскости сумма Минковского точна.
B7.3. Застревание в рампе
Замерено на настоящей карте: игрок намертво замирал на 45° рампе — позиция и
скорость не менялись кадрами. Причина: контакт с рамкой гасил гравитацию
вдоль нормали (clip_velocity), а on_ground оставался ложным, потому что в
пути отсечения опора не выставлялась. Равновесие без движения.
B7.4. StayOnGround — функции не было вообще
В CGameMovement после WalkMove всегда вызывается StayOnGround():
трассировка вверх на 2 юнита, затем вниз на m_flStepSize, и если под ногами
есть опора с normal[2] >= 0.7 — игрок притягивается к поверхности. Без этого
на спуске он висит над землёй (гравитация на FL_ONGROUND отключена и
возвращать его нечем), а на рампе не притягивается.
Порог опоры — sv_walkable_normal = 0.7, а не 0.6.
B7.5. StepMove только с земли
WalkMove вызывается только при GetGroundEntity() != NULL, иначе AirMove.
StepMove вызывается из WalkMove только при блокировке. То есть подшаг на
ступеньку в прыжке не работает — у нас работало.
B7.6. «Сквозь рампу я прошёл вообще»: 87% кирпичей не имело объёма
vertices_of брала пересечение двух граней и выбирала точку, ближайшую к
началу координат. Но пересечение двух граней — это прямая, а не точка:
выбранная точка оказывалась вне кирпича и отбрасывалась. У ящика из шести граней
ни одной вершины так не находилось вовсе.
Замер на gm_construct: 1846 кирпичей из 2126 (87%) считались «без объёма», то есть не твёрдыми: сквозь них можно было пройти, стоять на них было не на чем. Это и есть корень жалоб «сквозь рампу прошёл» и «падаю сквозь карту».
Починка: по каждой паре непараллельных граней строится сама прямая, затем обрезается всеми полупространствами кирпича — концы отрезка и есть вершины. После починки «без объёма» осталось 3 кирпича из 2126.
B7.7. Тело делало полный подшаг в стену
В clip_fraction стоял guard if start <= limit { continue }. Он отбрасывал
грань и при касании: t == 0 и даже -0.0 проходят проверку start <= limit
(сравнение с нулём ложно для -0.0 только в t < 0.0).
Замер: шаг 8 юнитов — стоп у стены; шаг 16 — на 8 юнитов внутрь стены. Дальше SAT выталкивал тело по чужой 45°-грани, то есть вперёд, сквозь кирпич.
Guard убран, условие отсечения — !(0.0..1.0).contains(&t). Двойная польза:
-0.0 отсекается верно, а NaN (сравнения с ним ложны) тоже не проходит
мимо проверки, как было при t < 0.0.
B7.8. Полуплоскость кирпича действовала на всю плоскость
Верхняя грань площадки «продолжалась» за её краем и держала игрока в воздухе навечно — «зависание над краем». Полупространство ограничивает плоскость, а не сам кирпич, поэтому одного раздутия граней мало.
Починка: swept-bounds early-out — грань отбрасывается, если swept-габарит тела заведомо не пересекает кирпич.
B7.9. Один общий процент шага вместо цикла касаний
Движение было одним общим fraction на всю траекторию: первое же касание
останавливало весь шаг, и игрок «запинкой» полз по полу.
Заменено на цикл касаний по образцу TryPlayerMove: MOVE_BUMPS = 4 попыток,
каждая — отдельное отсечение от всех кирпичей; касание гасит компоненту
скорости, упирающуюся в контакт, и цикл продолжает по оставшейся скорости.
B7.10. Подшаг всегда выбирал путь «вверх», а он уводил назад
StepMove в оригинале пробует оба пути и побеждает тот, что ушёл дальше по
горизонтали (if (flDownDist > flUpDist) — иначе подъём). Наша версия брала
подъём всегда, если он вообще не был заблокирован.
На крутой рампе путь «вверх-вперёд-вниз» уводит игрока НАЗАД по склону. Замер: игрок сползал по 45° рампе с x = -256 до x = -1066 за 5 секунд.
Переписано по оригиналу, включая m_vecVelocity.z = vecDownVel.z при победе
подъёма — иначе на рампе появляется паразитная вертикальная составляющая.
B7.11. StayOnGround падал всем шагом и выталкивал вдоль нормали
Тело, стоящее на рампе, чуть внутри раздутой полуплоскости (угол AABB ниже поверхности), и отсечение его не держит. Упав на всю высоту шага, тело вминалось в кирпич, и разрешение выталкивало его вдоль нормали — то есть вбок по склону. Это и был сползающий игрок.
Починка: зондирование вниз мелким шагом (1 юнит, до STEP_HEIGHT шагов)
вместо одного падения. Вминание ограничено той же величиной.
B7.12. Трение должно быть всегда на земле
FullWalkMove в Source на земле обнуляет вертикальную скорость и безусловно
зовёт Friction() — при любом вводе, а не только при его отсутствии. Именно
оно удерживает игрока на рампе. У нас трение было только без ввода.
B7.13. Линейная интерполяция ускорения несовместима с трением
v += (wish - v) * accel * dt замедляет не хуже, чем ускоряет: равновесие
устанавливается на 179 юнитах/с вместо 250 (замерено на gm_construct). Это
и была жалоба «хожу медленно».
Заменено на квото-формулу Source Accelerate — аддитивную:
addspeed = wishspd - dot(velocity, wishdir)
accelspeed = min(accel * dt * wishspd, addspeed)
velocity += wishdir * accelspeed
Она снимает ровно столько, сколько прибавляет трение, поэтому игрок выходит на
полную скорость. Ограничение sv_air_max_wishspeed = 30 действует только в
воздухе (AirAccelerate, GetAirSpeedCap()); на земле ограничения нет.
B7.14. Замер на настоящей карте
Тест real_map_movement (--ignored, путь через SOURCE_RS_TEST_BSP):
спавн gm_construct (704, 132, -143).
| жалоба | было | стало | в Source | |
|---|---|---|---|---|
| Скорость ходьбы | «хожу медленно» | 165 | 224,9 (среднее за 0,5 с с разгона) | 250 |
| Дёрганье назад | «кринит и запинаюсь» | — | 0,00 юнита за 3 с | 0 |
| Высота прыжка | «прыгать не реально» | не отрывался | 42,7 | ~45 |
| Падение сквозь мир | «падаю сквозь карту» | проваливался | не проваливается | не проваливается |
| Проход сквозь рампу | «сквозь рампу прошёл» | 87% кирпичей нет объёма | не проходит | не проходит |
Скорость 224,9 — среднее за первые полсекунды с разгона с нуля; на установившемся ходе она выходит на 250.
Старый тест real_map_player_rests_walks_and_does_not_sink требовал пройти
2000 юнитов вслепую вдоль +x, но на gm_construct там стена на x = 992 (288
юнитов от спавна). Ожидание теста было неверно, а не игрок; тест удалён, его
содержательно корректная часть покрыта real_map_movement.
Часть E6. Небо за границами карты (сделано)
E6.1. Жалоба и измерение
«если игрок выходит за карту, то скайбокс и 3D-скайбокс не должны рендериться (иначе не понятны границы карты)»
Причина видна прямо в шейдере 3D-скайбокса:
let world = camera.pos.xyz + (in.pos - sky.origin_scale.xyz) * sky.origin_scale.w;
Геометрия неба размещается относительно камеры, поэтому небо всегда заливает весь фон: куда ни вылети, оно рядом. Пределы карты на фоне не видно не из-за того, что неба «слишком много», а из-за того, что оно не привязано к миру вовсе.
Замер на gm_construct:
| значение | |
|---|---|
| Габариты геометрии карты | (-5640, -4576, -960) .. (2168, 6464, 2976) |
| Габариты коллизии | (-15360, -15360, -1024) .. (15872, 15360, 15359) |
sky_camera |
(-1428, 1645, 10991), масштаб 1/16 |
| Границы 3D-скайбокса | (-14848, -14336, 10654) .. (15360, 14848, 12520) |
2D-скайбокс (SKYBOX) |
отсутствует, только 3D |
| Спавн | (704, 132, -143) |
Видно, почему габариты коллизии не годятся: они включают и 3D-небо, и далёкие
террасы. Основание — габариты геометрии карты (World::bounds_min/max),
они же считаются по вершинам видимых поверхностей.
E6.2. Решение
World::inside_bounds(pos, margin) и свободная функция world::inside_bounds —
одна реализация, которой пользуются и движок, и рендерер (рендерер копирует
габариты при загрузке, а не хранит ссылку на мир).
WorldRenderer::update_sky_visibility пересчитывает видимость каждый кадр
из позиции камеры: игрок уходит за край уже во время игры. Проверка идёт по всем
трём осям сразу: под картой небо тоже исчезает, а не только сбоку.
SKY_BOUNDS_MARGIN = 128 — допуск наружу. Габариты считаются по вершинам, а
камера стоит на высоте глаз (около 64 юнитов) над опорой, так что стоящий на самом
краю игрок формально за габаритом на весь свой габарит.
E6.3. Тесты
sky_is_visible_only_inside_the_map— юнит на сам предикат, включая ровно на границе с допуском и на единицу за ней.real_map_sky_bounds(--ignored) — на настоящей карте: спавн внутри, за край по любой из шести осей небо гаснет.
Оба прохода неба (2D-фон и 3D-геометрия) гасятся одним и тем же флагом: иначе при вылете за край остался бы виден 3D-неб, а это ровно то, что мешало увидеть границу.
Часть B6.8. Гравитация пополам и CheckVelocity (сделано)
Два оставшихся пункта из B6.7, сверенные с оригиналом
(src/game/shared/gamemovement.cpp).
B6.8.1. Гравитация делится до и после движения
FullWalkMove начинается с StartGravity() и заканчивается CheckJump, который
зовёт FinishGravity(). Обе берут ровно половину гравитации:
// StartGravity
m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * 0.5 * frametime;
// FinishGravity — Get the correct velocity for the end of the dt
m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * frametime * 0.5;
Комментарий Valve прямо отмечен: «yes, this 0.5 looks wrong, but it's not». Половина добавляется, чтобы игрок оказался в правильной позиции во время движения, вторая — чтобы получить вертикальную скорость на конец кадра.
Проверено, что TryPlayerMove гравитацию не применяет: её зовут только
StartGravity / FinishGravity вокруг движения, так что «гравитация на каждое
касание» — не тот механизм.
Замер высоты прыжка на gm_construct после деления: 42,7 -> 44,9 юнита при ~45 в Source. Падение начинается на кадр раньше, а не на кадр позже.
B6.8.2. CheckVelocity: sv_maxvelocity попоосно
for (i=0; i < 3; i++) {
if (IS_NAN(m_vecVelocity[i])) m_vecVelocity[i] = 0;
if (m_vecVelocity[i] > sv_maxvelocity) m_vecVelocity[i] = sv_maxvelocity;
if (m_vecVelocity[i] < -sv_maxvelocity) m_vecVelocity[i] = -sv_maxvelocity;
}
Потолок — 350 по каждой оси. Без него шаг за кадр на большой скорости становится длиннее стены, отсечение её перестаёт видеть, и игрок проскакивает сквозь тонкие стены и пол.
Отдельно важно место вызова: в оригинале CheckVelocity() сидит прямо
перед TryPlayerMove() (внутри Friction()), то есть скорость приводится в
порядок до расчёта смещения. Наш вариант сначала звал его только в конце кадра —
одна нечислица в скорости уходила в смещение, отсечение её не видело, и позиция
становилась нечислицей навсегда: прогон тестов на этом занимал 42 секунды
вместо 0,01. Теперь вызов есть и перед движением, и в конце.
Позиция проверяется на нечислицу так же, как скорость (в оригинале — тоже, с обнулением оси на 0).
Тесты: speed_is_capped_at_max_velocity, non_finite_velocity_is_zeroed.
Часть B8. Свары GMod: константы и «невидимые стены» (сделано)
Четыре жалобы, все — из игры, все закрыты измерением.
B8.1. «В воздухе управление слабее, чем в оригинальном GMod»
Таблица cvar'ов GMod (gmodwiki.com/ConVars_In_Garrysmod) и код
AirAccelerate из CS:GO-версии gamemovement.cpp дают разные вещи:
wishspd = wishspeed;
if (wishspd > 30) wishspd = 30; // цель ограничена
addspeed = wishspd - currentspeed;
accelspeed = accel * wishspeed * frametime; // а прибавка — по полной
Кап sv_air_max_wishspeed = 30 ограничивает цель, но не величину прибавки.
Мы ограничивали и то и другое, из-за чего прибавка была в 250/30 ≈ 8,3 раза
слабее — это и есть ощущение «вялого управления в воздухе».
Замер после правки на gm_construct: рывок вбок в полёте даёт ровно 30,0 юнитов/с (кап достигается за один кадр, как в Source). Поворот при полёте вперёд по-прежнему заблокирован: проекция скорости на желаемое направление 177
капа 30, прибавки нет — это верное поведение оригинала, а не дефект.
Заодно: currentspeed считается по полной трёхмерной скорости, а не по
горизонтальной. В AirMove вертикаль не обнуляется, поэтому в падении управление
слабее, чем в горизонтальном полёте, — ровно как в Source.
B8.2. «Трение слабое, останавливаюсь долго»
Константы брались из SDK 2013, а у GMod они переопределены:
| cvar | SDK 2013 | GMod | было у нас |
|---|---|---|---|
sv_friction |
4 | 8 | 4 |
sv_stopspeed |
100 | 10 | 100 |
sv_gravity |
800 | 600 | 800 |
sv_airaccelerate |
12 | 10 | 12 |
sv_accelerate |
10 | 10 | 10 |
sv_maxvelocity |
350 | 3500 | 350 |
sv_stepsize |
18 | 18 | 18 |
sv_walkable_normal |
0,7 | 0,7 | 0,7 |
Замер после правки: с 250 юнитов/с до 10 — за 0,40 с, путь 26 юнитов.
sv_maxvelocity = 3500, а не 350, — отдельная история: это потолок по осям, и
при значении 350 вертикальная скорость падения упиралась в него сразу. Отсюда
жалоба «падаю как мешок, без ускорения».
B8.3. Прыжок и падение
sv_gravity = 600 и прыжок 270 дают 270²/1200 = 60,8 юнита высоты — как в
GMod (замерено 60,8). В Source с гравитацией 800 было 45,6.
Падение в пустоте по секундам: 600, 1200, 1800, 2400, 3000 юнитов/с —
равномерный разгон по g·t до потолка sv_maxvelocity. Прежде было
585, 589, 590 — то есть упиралось в потолок 350 и стояло на месте.
B8.4. О sv_falldistance / fall_speed: гипотеза, отброшенная измерением
Поначалу показалось, что «ускорение падения» — это пара cvar'ов HL2
sv_falldistance + fall_speed. Проверка: в таблице cvar'ов GMod их нет, а
формула velocity[2] -= velocity[2] * fall_speed * frametime при отрицательной
velocity[2] даёт замедление падения, а не разгон. Ставить непроверенные
константы значило бы гадать, поэтому пара не вводится: ускорение падения в GMod
даёт постоянная гравитация плюс высокий sv_maxvelocity.
B8.5. «Высота шага терялась единица»
Подшаг поднимает тело ровно на sv_stepsize = 18, и тогда низ габарита
становится точно на грань ступени. Грань в этот момент считается касанием,
отсечение обнуляет шаг, и подшаг блокирует сам себя.
Замер до правки: игрок перебирал уступы до 17 и застревал на 18. В
оригинале подъём идёт на flStepSize + DIST_EPSILON — не округление, а
необходимость.
После введения STEP_EPSILON = 0,1: перебираются все уступы до 18 включительно,
19 — нет. Ровно sv_stepsize.
B8.6. «Невидимые стены, рамка превращается в параллелепипед»
Грань кирпича ограничивает плоскость, а не сам кирпич. Стоит телу оказаться на уровне этой плоскости где угодно — грань останавливает шаг, даже если кирпича там нет.
Замер на gm_construct: игрок, идущий по полу на z = -144, останавливался за
3,4 юнита до настоящей преграды — блокировала верхняя грань соседнего
кирпича, до которого было далеко. Такие грани встречаются на каждом стыке
участков, а на наклонных поверхностях образуют блоки, сравнимые с габаритом
целого кирпича, — «рампа превращается в параллелепипед».
Правка: при t около нуля (шаг обнуляется полностью) проверяется, что точка
касания лежит на самом кирпиче, а не на продолжении его грани.
Проверка узкая и только для t < 0,001 намеренно: проверять точку касания для
всех t нельзя — при спуске с подшага тело опускается вертикально и в момент
касания ещё находится в стороне от грани по горизонтали. На широкой проверке
игрок переставал перебирать уступы уже высотой 4 юнита (замерено).
Замер после правки: преград «из воздуха» на 1800 шагов ходьбы по gm_construct — 0 (было 2), и упоров в бордюры за 30 с ходьбы — 0 кадров.
B8.7. Проверенные гипотезы, оказавшиеся невиновными
Каждая проверялась замером, а не рассуждением, и все оказались в порядке:
- Кисти кирпичей как ящик. Синтетическая наклонная грань сходится с
полутночными пространствами точно (
slanted_brush_is_not_treated_as_a_box). - Объём кирпичей на карте. Аудит всех 2126 кирпичей gm_construct по сетке 4×4×4 точек: 0 расхождений (129684 проверок).
- Displacement выпадает из сетки.
add_displacement_trisмолча выбрасывает треугольник, размазанный больше чем по 2048 юнитам. На карте: 11776 треугольников, выпало 0. - Скорость вдоль склона. Рампа — не ящик: 239–263 юнита/с против 250 на ровном, при подъёме застреваний 0.
Часть 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. Дубль есть в самой карте (две одинаковые плиты), это не наш баг.
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 этой плиты, вероятно, не видно (она внутри комнаты, а мы видим её сверху).
- Разобраться с плоской травой: снять кадр GMod из точки
ref_p4(setpos 0 0 2200,setang 180 55) и сравнить рельеф. sun_shadowбез проверкиndc.z— точка за ближней плоскостью карты теней получаетdepth < 0и трактуется как затенённая. Сейчас это не видно глазом, но диапазон не проверяется, и это лазейка для чёрных пятен.