- Текст HUD и меню теперь из атласа Liberation Sans (SIL OFL) 30 px:
ячейки 40×40, bbox-метрики в font_metrics.rs, глифы сглажены и
пропорциональны. Advance остался 8 px, поэтому вёрстка Lua не менялась;
bitmap 8×8 («не Derma») удалён, font8x8 из зависимостей убран.
- Подписи сенсорных кнопок масштабируются от размера кнопки (до 3.2×),
а не фиксированные 10 px.
- fs pack: +20 стандартных пропов (канистры, взрывная бочка, бетонный блок,
забор, скамья, колонна, дверь, стеллаж, комоды, шкаф, ванна, матрас,
столы/ящики №2, дверное колесо, кнехт). В APK 39 моделей, в спавнлисте
45 записей, иконки без заглушек.
- Android: hideSystemBars() прячет статус-бар и навигацию (API 30+ через
WindowInsetsController, старые — SYSTEM_UI_FLAG_*), повторно на
onWindowFocusChanged. ASSET_VERSION=4.
- Проверки: clippy -D warnings, cargo test --workspace — зелёные.
- Вьюмодель больше не живёт в мире глубины: рендерер получил отдельный
конвейер (depth_compare Always, без записи глубины), батчи вьюмодели
исключаются из мира/теней и рисуются последними. Пол и стены её не режут;
«телефото» (distance 80/scale 2.12) убрано — ближняя посадка 34/0.7,
right 20, up -15.
- Толкание пропов теперь работает от желаемого направления ввода, а не от
фактической скорости: контроллер обнуляет её, упёршись в проп, из-за чего
толкание не срабатывало, пока игрок не вставал на проп. Проверка касания —
пересечение AABB, а не круг по максимальному габариту (игрок казался
«жирным»).
- В noclip толкание не вызывается, капсула игрока уводится под карту —
коллизий с пропами нет.
- Диаграмма/доки обновлены.
Проверки: clippy -D warnings, cargo test --workspace — зелёные.
Иконки: видимая сетка оставалась белыми заглушками, потому что при
бюджете 8 мс на кадр успевало загрузиться всего 1-2 превью. Бюджет поднят
до 12 штук / 40 мс, кэш увеличен с 512 до 1024 текстур и живёт весь сеанс
(LRU, ключ — нормализованный путь модели). Добавлены IconStats
(cached/rendered/reused/evicted/failed) и логи при открытии, закрытии и
выходе: повторное открытие показывает rendered_since_open=0.
Размер меню: прогрев до создания окна задавал корню 1280×720, а
ui_set_screen не обновлял размер (Rust звал несуществующий
__sr_resize_to_screen). ui.set_screen/ui.sync_root_size теперь растягивают
g_SpawnMenu на ScrW×ScrH при открытии, resize и раскладке.
Скорость: при открытии меню больше не обнуляются velocity/sprint. Игрок
заморожен (update не вызывается), но импульс сохраняется: до открытия и
после закрытия speed одинаковый, включая noclip.
Hold/pin Q: Q down открывает, Q up закрывает только без взаимодействий.
Клик, колесо, касание и ввод закрепляют меню (pinned), после чего Q
работает как toggle. Esc закрывает всегда. Для текстовых полей добавлены
ui.focus/RequestFocus/GetKeyboardFocus, SDL3 TextInput, Backspace/стрелки/
Enter и input.IsKeyTrapping: в фокусе Q печатается, а не закрывает меню.
Попутно исправлена передача аргументов hook.Run: колбэки hook.Add больше
не получают имя хука первым аргументом, как в GMod. Из-за этого
OnTextEntryGetFocus падал на pnl:HasParent, и StartKeyFocus не вызывался.
Проверки: cargo fmt, clippy -D warnings, cargo test --workspace (28 сьютов)
зелёные. Кадры 1280×720 и 1600×900: images=88/104, placeholders=0; xdotool:
hold open/close, клик+keyup, ввод q, повторный Q, скорость 250.45 до/после.
В crates/client подключён UI-слой из glua: после Lua-инициализации
вызывается load_ui, и если OnGamemodeLoaded создал g_SpawnMenu, Q
открывает настоящее меню Sandbox, иначе остаётся qmenu.rs.
Рендер: ui_overlay переводит UiCommand в push_rect/push_text, цвета
sRGB→linear, бюджет глифов и отсечение за экраном. push_rect теперь
точный квад, а не растянутый глиф: тонкие линии дерева больше не
превращаются в квадраты.
Ввод: Q/Esc, мышь и колесо уходят в ui_mouse, игрок и камера стоят,
ЛКМ не стреляет; ui_set_screen на старте и resize. На Android добавлена
кнопка MENU и перевод касаний в пиксельные координаты.
Спавн: RunConsoleCommand складывает вызовы в очередь; клиент исполняет
gm_spawn (и gm_spawnsent/gm_spawnswep) обычным спавном пропа на полу
перед игроком.
Попутно: ui_snapshot_into переиспользует буфер, ui_take_errors отдаёт
ошибки панелей в лог; в VGUI-слое добавлены HasChildren/ChildrenSize/
HasFocus/GetTextSize/DrawTextEntryText, без которых сетка иконок имела
высоту 0 и клики не попадали.
- сборка мира: для каждого infodecal берётся ближайшая плоскость грани (замер:
у всех 19 декалей расстояние 0), центр проецируется на неё, квадрат строится
по осям текстуры грани (32×32 юнита), материал — `decals/<имя>`;
- рендер: материалы с префиксом `decals/` идут отдельным проходом с
ALPHA_BLENDING, без записи глубины, после непрозрачного мира; новый шейдер
`fs_decal` берёт цвет из обычного освещения, альфу — из текстуры;
- декаль освещается ambient'ом в своей точке (`model_light_at`).
Проверено кадром: граффити с прозрачностью лежит на кирпичной стене.
25 сьютов и 29 картовых тестов зелёные.
- сборка мира: модель зеркала помечается материалом-меткой `<mirror>` до
резолва материала (у reflectiveglass001 нет VTF, иначе грань выпадала),
из её граней строится плоскость MirrorPlane (origin, normal);
- рендер: проход отражённой камерой в текстуру Rgba16Float, клип по плоскости
зеркала (vec4 в uniform камеры, discard в шейдере), грань зеркала сэмплирует
отражение по экранным координатам;
- отражение привязано на слот лайтмапы (группа 3) отдельным bind group:
у устройства max_bind_groups = 4, пятая группа не создаётся;
- обычный проход пропускает зеркальные батчи (иначе чекер-заглушка
перекрывала отражение), зеркало рисуется после непрозрачного мира;
- найден и исправлен пропуск слота `sun` в Camera у world.wgsl (из-за него
fog_color читался со сдвигом).
Проверено: 25 сьютов и 29 картовых тестов зелёные; UV-градиент на грани
подтвердил плоскость и шейдер, отражение показывает стену и потолок.
Пользователь: «диван в светлой комнате тёмный, чуть ближе к свету — светлый,
хотя пол одинаковый» и «свет обновляется резко».
Причины две:
1. Модели вообще не получали свет карты: у них `lightmap_uv` указывал на белый
тексель атласа, то есть ambient + солнце без привязки к месту. Теперь при
сборке мира собираются `light_samples` (центр грани + средний свет её блока
лайтмапы), а модели берут ambient в своей точке с плавным ядром по
расстоянию (радиус 256, ядро 1/(d²+256)), с нижним порогом 0,35 — одна
ближайшая грань давала скачки «тёмный/светлый». `lightmap_uv.x < 0` —
метка шейдеру брать свет из вершины; свет обновляется и при движении
(rewrite_instance и play_model_animation).
2. Каскад теней был 2600 юнитов вокруг камеры: объект у стены выходил за его
границу и «вспыхивал» при приближении камеры. Теперь 4200 юнитов и карта
4096² — вся игровая зона gm_construct попадает в тень.
Все сьюты зелёные.
- PostProcess: 3D-текстура LUT 32³ Rgba8Unorm (по умолчанию тождественная),
сэмплер, биндинги 4/5 в композите; set_color_lut принимает raw Source
(32³ RGB, красный меняется быстрее всего) и включает флаг use_lut.
- post.wgsl: после ACES цвет переводится в sRGB, сэмплируется LUT и
возвращается в линейный (поверхность sRGB-ная).
- Клиент: читает materials/colorcorrection/<карта>.raw из VFS; для
gm_construct файл есть. Отключение — SOURCE_RS_NO_COLORCORRECTION=1.
- Замер: gm_construct.raw нейтральный (0/66/132/195/255 по диагонали), то есть
грейда у карты нет by design — цветокор включён, но визуально не меняет.
- Тест real_map_visual_entities: на карте есть func_reflective_glass (зеркало),
env_tonemap_controller, 19 infodecal, env_sky/tonemap — записано в дневник.
Все сьюты зелёные.
Прогон на коммите до правок с той же инструментацией дал 23,6–26,5 fps;
ветка с HDR — 22,1–26,4, NO_HDR — 23,0–25,7. Разница в пределах разброса,
а не 3% регрессии из прошлого замера (тот шёл параллельно с другим клиентом).
Плюс переключатель SOURCE_RS_NO_SHADOW: нулевая матрица теней для замера их
стоимости (на 320×180 разницы нет — PCF не узкое место).
Диагностика коллизий больше не проходит тон-маппинг: её конвейеру не нужна
глубина, и она рисуется тем же проходом, что и текст, уже в sRGB-поверхность.
Поведение прежнее: линии видны сквозь геометрию, цвета не меняются от ACES.
Свет больше не интерполируется по вершинам: LUMP_LIGHTING раскладывается в
атлас Rgba16Float (рамка 1 тексель на блок), в вершине появился lightmap_uv,
шейдер мира читает атлас на каждый пиксель с билинейной фильтрацией. Это
убирает перелом света по рёбрам брашей (A10). Для карт без атласа остаётся
вершинный путь fs_main_vertex.
Мир, небо и вода рисуются в Rgba16Float; затем экспозиция, ACES и bloom
(порог в ½ разрешения, два прохода размытия, аддитивно). UI и диагностика
идут отдельным проходом в sRGB-поверхность. Переключатели: SOURCE_RS_NO_HDR,
SOURCE_RS_EXPOSURE, SOURCE_RS_BLOOM, SOURCE_RS_BLOOM_THRESHOLD.
PCF теней: 9 отсчётов с поворотом по мировой позиции и нормальным смещением,
режим SOURCE_RS_DEBUG_SHADOW. Плюс SOURCE_RS_NO_VSYNC и SOURCE_RS_LOG_FPS для
замеров.
Тесты: lightmap_atlas_maps_face_block, half_conversion_known_values; все
прежние сьюты зелёные. Замер E11: ~22,1 fps до, ~21,0 с HDR (~3% на пост),
NO_HDR ~21,7.
Падение wgpu на втором спавне («Copy at offset ... overrunning the bounds»):
буферы GPU создавались под размер мира при загрузке и не росли. Появился
WorldRenderer::ensure_capacity (рост вдвое + перезаливка) и refresh_materials
(bind group для материалов, появившихся после спавна, — иначе новые батчи
молча не рисуются).
Вьюмодель:
- обновляется после player.update и camera.pos, а не до них: раньше она
отставала на кадр при ходьбе и «плыла» при повороте (жалоба пользователя);
- по умолчанию w_pistol.mdl вместо v_pistol.mdl: v_pistol у нас рисуется
развёрнутым полотном (2157 вершин, 2 меша — разбираться отдельно), а w_pistol
выглядит пистолетом;
- курс -90°: у модели ствол смотрит вдоль +Y (влево), а не +X — без доворота
пистолет лежал поперёк кадра;
- дистанция 26, сдвиг вправо/вниз, анимация idle01 (у w_pistol свои кадры
в MDL).
bone_world_transforms теперь обходит кости рекурсивно: порядок «родитель
раньше ребёнка» в файлах не гарантирован.
HUD: журнал и отладочные строки скрыты по умолчанию (клавиша `), строка
verts/tris убрана.
Плюс тесты на реальных ассетах: кости вьюмодели не разваливаются при
скиннинге (макс. сдвиг 4 юнита), структура моделей оружия, встроенные кадры.
Все сьюты зелёные, clippy чистый.
Ещё две поломки на пути «новой геометрии»:
1. Буфер вершин мира создавался без COPY_DST, поэтому точечная заливка
диапазонов падала с ошибкой wgpu (у пользователя это уронило бы клиент —
поймано прогоном, а не тестом). Теперь VERTEX | COPY_DST, для индексов
INDEX | COPY_DST, добавлен upload_index_ranges.
2. Рендерер копировал список батчей при сборке, поэтому добавленная геометрия
(спавн) в кадр не попадала до полной пересборки. Добавлен
WorldRenderer::refresh_batches: когда появились новые индексы, список батчей и
признаки воды перечитываются, а полная пересборка нужна только при новой
текстуре.
Вьюмодель (A23, первый срез): создаётся тем же рендером моделей, следует за
взглядом, включается SOURCE_RS_VIEW_MODEL (по умолчанию физган, откат на
монтировку — v_physgun.mdl в ассетах не нашёлся). В кадре она пока не
появляется: создание работает, а звено «перенос позы каждый кадр + заливка»
нет — записано открытым вопросом в docs/DIARY.md.
Все 23 сьюта и 28 картовых тестов зелёные.
Жалоба пользователя: «физика вроде работает, но позиция рендера обновляется
только при спавне нового». Причина: мир двигал вершины в памяти, а буфер
рендера заливался один раз при сборке мира — потому пересборка рендерера при
спавне и «лечила» картинку.
Сделано: World::moved_ranges и take_moved_ranges (диапазоны заполняет
rewrite_instance, то есть любое движение пропа, модели игрока или физгана),
WorldRenderer::upload_vertex_ranges — точечная заливка queue.write_buffer по
этим диапазонам, и вызов в кадре клиента сразу после шага физики.
Тест probe_moving_prop_reports_vertex_ranges (диагностика, ignored) пока не
сходится и это записано честно: коробка в тесте задана в мировых координатах,
а меш обязан быть в локальных (видно по двойному смещению вершины), и список
диапазонов после step_physics пуст — проверить is_sleeping у тела после
телепорта. Проверка в интерактиве: заспавнить арбуз и посмотреть, падает ли он
на экране.
Все 23 сьюта и 28 картовых тестов зелёные; разбор в docs/DIARY.md.
Fog читается при сборке мира (приоритет env_fog_controller, иначе worldspawn),
доля тумана считается линейно от fogstart к fogend с потолком fogmaxdensity —
как в движке. В рендере два новых vec4 в uniform камеры (цвет и параметры),
буфер камеры 112 → 144 байта; world.wgsl смешивает цвет с туманом по
расстоянию до камеры.
Замер на gm_construct: туман есть — цвет (0,557 0,643 0,698), start 1024,
end 250000, max_density 1,0, farz −1. Это дальний туман: на 2000 юнитов доля
≈ 0,4%, поэтому кадр с камеры E11 не изменился (трава 73,2, бетон 116,0,
небо 207,6, тоннель 81,1 — как в E20). Механика подключена и проверена; заметен
туман будет у карт с близким fogend.
Осталось: применить туман к воде и к моделям. Все 23 сьюта и 28 картовых
тестов зелёные. Разбор — docs/PARITY.md E24.
Жалобы «невидимая стена», «стою вписанным в параллелепипед вокруг рампы»,
«на стыках комнат стена, которую можно перепрыгнуть, но не запрыгнуть»
невозможно разобрать по картинке: стена невидима по определению. Теперь
коллизию рядом с игроком можно увидеть.
`SOURCE_RS_DEBUG_COLLISION=<радиус>` (по умолчанию 500) включает слой:
- красное — рёбра самих кирпичей, то есть истинная форма по плоскостям
(рампа выглядит рампой, а не ящиком);
- жёлтое — рёбра габаритов: именно их интервалы берёт `penetration`, поэтому
расхождение красного с жёлтым и есть «параллелепипед вокруг рампы»;
- голубое — треугольники коллизии рельефа;
- зелёное — тело игрока.
Слой рисуется поверх мира без участия глубины (иначе невидимую стену не
отличить от настоящей) и работает как в живом окне, так и в кадрах пролёта.
Кирипчи строятся из пересечений пар граней, обрезанных остальными
полупространствами: `Brush::vertices_of` разделён на `segments_of`, и рёбра
(core) отдаёт новый `Brush::edges`.
Замеры, сделанные этим слоем и `probe_ramp_movement` (тоже в этом коммите):
в точке игрока `-2438 -966 48` (рампа #1891, 9 граней, 448x128x204 со
склоном) залипания при движении в −Y и по диагоналям объясняются настоящей
кирпичной стеной: кирпич #829 стоит по `y = -1024` и она видна на кадре.
Фантомного выталкивания в этой точке нет. Значит источник «невидимых стен»
в другом месте, и искать его надо обходом с сеткой по всей карте, а не по
центру: старая сетка обхода (x −1500..600, y −800..600) до запада и юга не
доходит вовсе.
В список дефектов (docs/PARITY.md, часть A) добавлены A9..A14: невидимый
параллелепипед у рамп и стыков, перелом света по полигонам (лайтмапа
берётся в вершинах, а не на пиксель), две беды воды (прозрачность слева и
заливка справа, мелкие одинаковые по Y волны), туман и присед без смены
скорости.
Жалобы «дисплейсменты вывернуты» и «сквозь них видно только скайбокс»
оказались одной механикой. Меш 3D-скайбокса рисуется без записи глубины и
без отсечения, поэтому всегда заполнял то, что мир не нарисовал, и маскировал
пропуск: вместо травяных холмов gm_construct был виден чужой ландшафт
скайбокса, а из-под карты изнанка рельефа читалась как «трава сверху».
Замер: геометрия рельефа в буфере здоровая (0 вырожденных, 0 нефинитных,
габариты — земля карты), батч выдаётся, но `--only-material 36` даёт пусто и
только `SOURCE_RS_NO_CULL=1` возвращает `#93ff26` на экран. Замер верхней
поверхности над точкой: пол под спавном (z=-144) намотан с кросс-произведением
вниз, трава (z=-148) — вверх. Видимая сверху поверхность — та, у которой
кросс-произведение смотрит внутрь тела; браш-грани приводятся к этому в
`face_winding`, а `build_displacement` подгонял намотку вдоль нормали
плоскости, то есть наоборот.
Порядок индексов в `emit` перевёрнут; нормали вершин оставлены наружу, чтобы
динамический свет не поехал. Коллизия не задета — `add_displacement_tris`
строит нормали по рёбрам, порядок вершин для неё неважен.
Новая диагностика: переключатель `SOURCE_RS_NO_SKY_MESH` (гасит только меш
скайбокса), лог «топ-материал по объёму геометрии» и предупреждение о
батчах с индексом вне списка материалов — рендер пропускает такие молча.
Отмечу: в тестах мир строится с пустым VirtualFs, из-за чего 189 материалов
против 54 в рантайме и индексы расходятся, поэтому диагностика печатается из
сборки мира.
Тесты: real_map_top_surface_orientation (замер), real_map_displacement_is_visible_from_above
(держит правку). Все 22 теста с картой зелёные.
Ошибка была в моём же исправлении из E11. Экспоненту из четвёртого байта там
отбросили, потому что у большинства люкселей байт равен 255, что как i8 даёт
-1 и масштаб 0,5, и картинка выходила вдвое темнее. Взамен подобрали плоский
коэффициент 3,0, и по освещённым поверхностям совпало.
Но затенённые участки выходили втрое светлее эталона, и это было записано как
«не найдено». Причина: VRAD нормализует мантиссу так, что её максимум лежит в
верхней половине байта, а порядок яркости пишет в экспоненту. Отбросив её, мы
сравняли тени с солнцем.
Масштаб подобран так, чтобы освещённые участки остались: 2^(-1) * 4,47 = 2,24.
Тень с экспонентой -3 получает 2^(-3) * 4,47 = 0,56, вчетверо темнее солнца.
Та же камера, что в E11:
бетон в солнце GMod 143,6 -> было 147,0, стало 143,7
тоннель GMod 92,9 -> было 165,8, стало 86,8
стена в тени GMod 36,6 -> было 124,6, стало 70,8
небо GMod 199,7 -> 196,9 без изменений
Тоннель — то место, где затенение и должно быть сильным: 165,8 -> 86,8 при
эталонных 92,9. Это и есть появившиеся тени.
Проверено, что динамическое солнце эти участки не освещает: при
SUN_BRIGHTNESS_SCALE 900 и 2500 значения совпали до десятых.
Вода: ряд длин 46/23/11/5,5/2,7 был кратен ×0,5, из-за чего периоды фаз всех
пяти волн синхронизировались и рябь выстраивалась в правильную решётку — на
кадре это читалось как гофрированный металл. Заменено на шесть волн с
несоизмеримыми длинами 340/210/125/73/41/19.
Тест lightmap_ignores_exponent_byte переименован в
lightmap_exponent_byte_makes_shadow_darker и проверяет, что разница экспонент
на две ступени даёт вчетверо.
Теней в движке не было вообще — это и было причиной того, что плоская трава
читалась как «вывернутая», хотя намотка рельефа верна: плоскости 110 граней
смотрят вверх, развёрнуто 0 из 11 776 треугольников. Проверка переворотом
подтвердила: при инверсии становится вверх 8 008 / вниз 10 353 и разворачиваются
все треугольники, то есть явно хуже.
Добавлена карта теней 2048x2048 (Depth32Float), ортографическая проекция на
область радиусом 2 600 юнитов вокруг камеры по направлению на солнце. Отдельный
пасс глубины со стороны источника; вода в него не идёт — она полупрозрачна.
В шейдере луч сравнивается с картой четырьмя сравнениями по соседним текселям
и сглаживается smoothstep, иначе край тени идёт лестницей. Смещение по
нормали на 3 юнита убирает полосу самозатенения.
Солнце в небе: направление пишется в uniform камеры шестым vec4, небо рисует
ядро 2,5 градуса и ореол до 12.
Сверка с GMod на той же камере, что в E11:
бетон 143,6 -> 147,0 (было 87,8)
трава 127,1 -> 129,0 (было 77,3)
небо 199,7 -> 196,9
Расходится стена в тени: GMod 36,4, у нас 124,6. Основной свет даёт запечённая
лайтмапа, а динамическое солнце при SUN_BRIGHTNESS_SCALE = 900 вносит малую
долю, поэтому затенение этой стены у нас не гаснет. Причина не найдена,
записана в PARITY.md E12.5.
Заодно добавлен Mat4::ortho в math.
Затухание было 1/(1 + d²·0.00012) — на 900 юнитах это 0,01, и озеро с высоты
читалось зеркальной плитой. Алиасинг бьёт только по мелким волнам: дальше
пикселя. Заменено на затухание по волновому числу каждой волны отдельно,
local = 1/(1 + (k·d·0.01)²). Волна длиной 46 юнитов на 900 юнитах ещё видна
(0,4), длиной 2,7 — уже нет (0,002).
discard считался по волновой нормали: на скользящих углах dot(n, view)
проваливался в ноль и поверхность выбивалась дырками, край озера рвался
зубцом. Теперь facing берётся по плоской поверхности — это вопрос геометрии,
а волновая нормаль идёт только в френель и блик.
Сверено с настоящим GMod на одной камере: точка (-399.5, -1604.5, 67.9),
yaw 146.33, pitch 3.48, fov 100. Наш кадр снят в той же точке, ракурс совпал.
Первое сравнение: небо сошлось (0,97-1,00), а поверхности были темнее вдвое.
Причина — четвёртый байт люкселя читался как i8 и давал масштаб 2^exp; у
большинства люкселей он равен 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
Один коэффициент попал в эталон на двух материалах, значит это найденная
величина, а не подгонка.
Заодно:
- водa: сумма герстнеровских волн вместо ровной синусоиды — острые гребни и
широкие ложбины; референсы дают прямое указание «Gerstner, not sine»;
- плавание: вход подавался как направление (-1..1), а не скорость, поэтому
горизонтальная скорость была 0,8 юнита в секунду вместо 200;
- ноклип: скорость при отпускании клавиш не обнулялась — это инерция; теперь
обнуляется, а пробел такой же ввод, как «вперёд»;
- добавлен --fov, по умолчанию 100 как в GMod.
Тесты на плавание и на инерцию в ноклипе добавлены.
Симптом: «на видео нету освещения вообще». Предыдущий замер показывал
среднюю лайтмапу 0,22 и делал вывод, что темно «от данных карты». Вывод был
неверен.
Замер ориентаций вершин gm_construct вскрыл причину: строго вверх — 17
вершин, строго вниз — 18 096. У настоящей карты пола смотрят вверх.
Причина: face_normal брала нормаль плоскости с обратным знаком
(`if side == 0 { -normal }`). В VBSP нормаль плоскости грани уже смотрит
наружу от твёрдого тела. Инверсия попала в код при попытке починить
намотку, но намотку чинит отдельная face_winding — она сравнивает
геометрическую нормаль треугольника с нормалью грани и переворачивает обход
сама.
Следствие: солнце и запечённая лайтмапа попадали на изнанку всех
поверхностей. Пол не получал ни того, ни другого.
Стало: вверх 15 195, вниз 2 918. Тест real_map_normals_are_not_inverted
не даёт дефекту вернуться.
Заодно второй настоящий баг в том же месте: в sample_light_luxel ширина
строки блока лайтмапы считалась как extent[0], а блок — это
(extent[0]+1) × (extent[1]+1) люкселей. Каждая строка читалась на люксель
раньше, и к низу блока чтение уезжало на десятки люкселей в сторону.
Долго висевшее «белое пятно» оказалось не дефектом: это комната Construct,
материал GM_CONSTRUCT/COLOR_ROOM с VMT UnlitGeneric и базовой текстурой
color/white — белая по замыслу и неосвещаемая. Спавн игрока внутри неё.
Рябь на воде подобрана замером: частота 0.035 давала плиту, 0.35 —
aliasing на горизонте, остановились на 0.12 и 0.19; третью полосу убрали —
её наклон давал клинья поперёк озера.
Был плоский mix(deep, sky, fresnel * 0.85) — вода стояла зеркальной плитой.
- Рябь строится из трёх синусоид разной частоты и направления в мировых
координатах, с затуханием к горизонту: вдали вода должна быть гладкой, иначе
горизонт превращается в кашу.
- Френель по возмущённой нормали, а не по исходной.
- Отражение неба зависит от угла: у горизонта светлее и холоднее, сверху
темнее.
- Узкий блик от солнца по отражённому вектору (степень 220) — у воды блик
игольчатый, а не матовая подсветка.
- Прозрачность зависит от френеля: вблизи видно дно, гладкая поверхность
почти зеркало.
Время для анимации приходит из буфера камеры (добавлено пятое vec4), а не
выводится из позиции: иначе картинка дёргалась бы при повороте.
На gm_construct солнце светило строго с горизонта: направление на свет было
(0.588, -0.809, 0) — Z ноль, то есть полы не получали света вообще, а светил
только бок, попавший под рыскание.
Причина: у light_environment в GMod наклон лежит в ОТДЕЛЬНОМ ключе pitch,
а в angles стоит только рыскание. На карте буквально angles = "0 306 0" и
pitch = "-56", а читались только angles.
Теперь направление (-0.329, 0.452, 0.829) — полы получают 83% солнца.
Тест real_map_sun проверяет, что Z солнца больше 0.5.
Заодно небесная добавка берётся из _ambient той же сущности
("114 136 148 420" на gm_construct) вместо жёсткой константы
[0.62, 0.66, 0.74]: карты с собственным небом освещались чужим цветом.
Жалоба: «если игрок выходит за карту, то скайбокс и 3D-скайбокс не должны
рендериться (иначе не понятны границы карты)».
Причина видна в шейдере 3D-скайбокса:
world = camera.pos.xyz + (in.pos - sky_camera) * scale
Геометрия неба размещается относительно камеры, поэтому небо всегда заливает
весь фон — пределы карты на нём не видно.
Замер на gm_construct:
габариты геометрии (-5640, -4576, -960) .. (2168, 6464, 2976)
габариты коллизии (-15360, -15360, -1024) .. (15872, 15360, 15359)
sky_camera (-1428, 1645, 10991), масштаб 1/16
2D-скайбокса нет, только 3D
Габариты коллизии не годятся — включают и 3D-небо, и далёкие террасы,
поэтому основание — габариты геометрии карты.
- world::inside_bounds — одна реализация предиката, её используют и движок,
и рендерер (рендерер копирует габариты при загрузке).
- WorldRenderer::update_sky_visibility пересчитывает видимость каждый кадр:
игрок уходит за край во время игры. Проверка по всем трём осям сразу,
под картой небо тоже исчезает.
- SKY_BOUNDS_MARGIN = 128: габариты считаются по вершинам, а камера стоит
на высоте глаз над опорой, так что стоящий на краю игрок формально за ними.
- Гасятся оба прохода неба: иначе за краем остался бы виден 3D-неб, а это
ровно то, что мешало увидеть границу.
Тесты: sky_is_visible_only_inside_the_map (юнит) и real_map_sky_bounds
(замер на настоящей карте).
collect_lights() разбирает light_environment (солнце), light, light_spot.
На gm_construct: 65 источников, 1 солнце, 22 прожектора. Шейдер получил
uniform-блок с солнцем, точечными источниками и конусами прожекторов.
Ключевые находки при подборе нормировки (проверено рендерами):
- четвертая компонента _light — это РАДИУС у ламп и ЯРКОСТЬ у солнца;
- яркость нельзя домножать в цвет, иначе возводится в квадрат;
- лайтмапа уже содержит запеченное солнце, динамическое добавляется слабо.
Также добавлен разбор GAME_LUMP в formats (нужен для деталь-текстур) и
заодно выяснено: в gm_construct деталь-текстур нет, под-лумпы prps/prpd —
это геометрия пропов. Текстура GRASS-SAND_13 есть, но не используется ни
одним texinfo — вопрос песка остается открытым (docs/PARITY.md A5).
A4 из docs/PARITY.md. Раньше cull_mode был None во всех пайплайнах, и
изнанка брашей рисовалась поверх настоящих поверхностей (на gm_construct
трава была видна сквозь бетонную плиту).
Намотку нельзя задать «как в карте»: в данных она непоследовательна.
Замер на gm_construct показал, что у обычных граней и у displacement'ов
противоположный порядок обхода, а нормаль плоскости у них трактуется
с разным знаком. Поэтому:
- face_normal: нормаль плоскости смотрит внутрь тела, лицевая сторона —
обратная (side == 0 => -normal);
- face_winding: геометрическая нормаль каждой грани приводится к нормали
грани, треугольник разворачивается при несовпадении;
- build_displacement: то же для рельефа, причём «наружу» — это нормаль
БАЗОВОЙ плоскости (на склонах геометрическая нормаль наклонная).
После этого инвариант «геометрическая нормаль = нормаль освещения» держится
везде, и правильная фронт-грань одна: Ccw в NDC, то есть Cw в wgpu/Vulkan
(Source использует левую систему координат).
Проверено рендерами на gm_construct: рельеф виден сверху целиком, плита и
трава вместе, трава корректно перекрыта плитой. Диагностические флаги
SOURCE_RS_NO_CULL и SOURCE_RS_FRONT_FACE=ccw оставлены для сверки.
- Вода: детект Water-шейдера (/), отдельный полупрозрачный
проход с френелем; поверхность/изнанка по нормали. Белая дыра на месте воды
(2D-скайбокс «вниз») устранена.
- 3D-скайбокс: выбор граней по area листа sky_camera (движок: area bits), а не по
AABB. Disp-грани не попадают в leaf_faces — выбор по листу центра грани; теперь
в скайбоксе есть большая плоскость flatgrass_2 (пропадала — торчала «стена»
бетонного пола). Защита >30% граней = скайбокс не выделяем (gm_flatgrass).
- Порядок: 2D-скайбокс фоном -> 3D-меш (depth Always, без записи) -> мир ->
вода. Скайбокс больше не проступает сквозь дальние стены карты.
- Лайтмапы: блок грани (size+1)^2 с индексом v*width+u; для displacement'ов —
плотная сетка по UV поверхности (CalcDispSurfCoords), а не проекция мира:
убрана «шахматка» теней на рельефе. Скайбокс-меш тоже сэмплит лайтмапы.
- Тесты: gm_flatgrass-диспы живут в скайбоксе (z≈-16000), пороги обновлены.
- docs/RECON.md §12: форматы листьев/граней, лайтмапы, скайбокс, вода.
- render::text: атлас ASCII 32..127, вершинный оверлей, шейдер text.wgsl
- WorldRenderer: push_text/clear_text, пайплайн с альфа-смешиванием, depth Always
- capture(): теперь использует общий draw() — раньше скриншоты не включали
скайбокс и оверлей (важно: скайбокс-меш на скриншотах появился только сейчас)
- map: HUD (FPS, позиция, режим, подсказки, лог событий), тени текста
- engine::model: сборка мешей MDL+VVD+VTX (bind pose, LOD 0), материалы через
cd-каталоги модели, поворот углами Source
- build_world: экземпляры prop_* моделей в общий меш мира (9 инстансов на
gm_construct), кэш моделей и текстур
- 3D-скайбокс: выделение скайбокс-граней (AABB вокруг sky_camera, <30% карты),
сжатие 1/scale вокруг камеры, отдельный пайплайн с глубиной у дальней
плоскости; 2D-скайбокс рисуется только в незакрытых пикселях
- тесты: sky_mesh на gm_construct, пороги дисплейсментов обновлены