201 lines
15 KiB
Markdown
201 lines
15 KiB
Markdown
|
|
# 10 — Хоррор-слой: свет, туман войны, трассеры, напряжение
|
|||
|
|
|
|||
|
|
Весь этот слой — **render-only**. Симуляция о нём не знает: `RenderGame` принимает
|
|||
|
|
`const Game&`, поэтому ни свет, ни трассеры, ни тряска камеры физически не могут
|
|||
|
|
изменить состояние симуляции. Headless-прогоны (`--accept`, `--headless`) `View`
|
|||
|
|
не создают вообще, и цифры приёмки остаются цифрами симуляции.
|
|||
|
|
|
|||
|
|
> Раздел 12 исходного спека запрещал освещение и туман войны. Заказчик изменил
|
|||
|
|
> требования — теперь это ядро подачи. Правила стрельбы (раздел 6) не тронуты.
|
|||
|
|
|
|||
|
|
## Порядок кадра
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Clear(чёрный) ступень 0 рампы == цвет очистки
|
|||
|
|
-> свет и туман (Lighting::Update)
|
|||
|
|
-> пол: BlitShaded, тёмные тайлы пропускаются
|
|||
|
|
-> список сортировки: стены, мишени, агенты — тоже BlitShaded
|
|||
|
|
-> глаза мишеней (аддитивно)
|
|||
|
|
-> трассеры, вспышки дула, искры (аддитивно)
|
|||
|
|
-> ОДИН слитый пост-проход: виньетка, зерно, сердцебиение
|
|||
|
|
-> Present
|
|||
|
|
-> debug-оверлей (raylib, dev-only, F1)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Почему свет вплавлен в блит, а не сделан пост-проходом
|
|||
|
|
|
|||
|
|
Соблазн очевидный: пройти по 129600 пикселям, для каждого получить мировую точку
|
|||
|
|
через `iso::ScreenToWorld` и умножить на освещённость. Так делать нельзя.
|
|||
|
|
|
|||
|
|
Спрайт стены имеет привязку `ay = TILE_HALF_H + TILE_H = 24`, то есть верхняя
|
|||
|
|
грань поднята на 16 px над ромбом своего тайла. Обратная проекция этого пикселя
|
|||
|
|
даёт `ScreenToWorld(0, -16) = (-1, -1)` — **ровно соседний тайл на северо-запад**.
|
|||
|
|
Пост-проход осветил бы крыши стен чужой видимостью: чёрные крыши на освещённых
|
|||
|
|
стенах и светящийся ореол на тайл северо-западнее каждой стены. Чинится это
|
|||
|
|
только буфером высот на пиксель, то есть честным depth-буфером, которого в
|
|||
|
|
проекте нет и заводить его ради этого не стоит.
|
|||
|
|
|
|||
|
|
Поэтому свет модулирует цвет **на этапе блита спрайта**: спрайт целиком светится
|
|||
|
|
от своего тайла, и артефакт исчезает конструктивно, без единой проверки.
|
|||
|
|
|
|||
|
|
## Модель света
|
|||
|
|
|
|||
|
|
Одна скалярная величина на точку мира плюс **рампа** — таблица
|
|||
|
|
`g_shadeLut[32][3][256]`, переводящая уровень в цвет (подход COLORMAP из Doom).
|
|||
|
|
Цветокоррекция зашита в таблицу: в полумраке картинка уходит в холодный синий,
|
|||
|
|
на свету нейтральна, во вспышке выбивается в тёплый пересвет. На пиксель
|
|||
|
|
остаются три выборки из таблицы и ноль умножений.
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
tile = min(1, max(память, видимость * sightBrightness) + вспышки)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| Состояние | Значение | Как выглядит |
|
|||
|
|
|---|---|---|
|
|||
|
|
| никогда не видели | 0 | чистый чёрный, тайл вообще не рисуется |
|
|||
|
|
| видели раньше | `fogMemoryLevel` | тускло и холодно, сущностей нет |
|
|||
|
|
| видно сейчас | `sightBrightness` | нормальная яркость |
|
|||
|
|
| вспышка выстрела | выше | тёплый пересвет |
|
|||
|
|
|
|||
|
|
Диапазон выше `sightBrightness` намеренно зарезервирован под динамический свет:
|
|||
|
|
выстрел всегда «выбивает» кадр вверх и в тепло.
|
|||
|
|
|
|||
|
|
### Билинейка без швов
|
|||
|
|
|
|||
|
|
Узловая сетка 49×49 (`corner`), узел = среднее смежных тайлов. Патч света на
|
|||
|
|
тайл раскладывается в координатах спрайта без перекрёстного члена:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Bu = c10-c00, Bv = c01-c00, K = c00-c10-c01+c11
|
|||
|
|
a = c00 - (Bu-Bv)/2 - K/4 b = (Bu-Bv+K)/TILE_W c = (Bu+Bv)/TILE_H
|
|||
|
|
e = -K/(TILE_W*TILE_W) f = K/(TILE_H*TILE_H)
|
|||
|
|
L(px,py) = a + b*px + c*py + e*px^2 + f*py^2
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Это **точная** билинейка по четырём вершинам ромба: подстановка (16,0), (32,8),
|
|||
|
|
(0,8), (16,16) даёт ровно c00, c10, c01, c11. Значит на общем ребре двух соседних
|
|||
|
|
тайлов значения совпадают и швов нет. По строке считается прямыми разностями —
|
|||
|
|
во внутреннем цикле два сложения на пиксель.
|
|||
|
|
|
|||
|
|
Блочность добивают четыре вещи: узловая сетка, точная билинейка, временное
|
|||
|
|
сглаживание (`fogFadeRate`) и упорядоченный дизеринг Байера 4×4 — 32 ступени
|
|||
|
|
рампы выглядят как ~200, полос не видно.
|
|||
|
|
|
|||
|
|
## Видимость
|
|||
|
|
|
|||
|
|
Для каждого агента перебираются тайлы в квадрате радиуса обзора; для каждого —
|
|||
|
|
затухание по дальности × max(конус взгляда, круговой «личный» ореол), затем
|
|||
|
|
проверка LOS уже существующим `Tilemap::RaycastBlocked`. Рейкаст делается
|
|||
|
|
последним: дальность и конус отсекают большинство тайлов даром.
|
|||
|
|
|
|||
|
|
Стену нельзя проверить лучом до её центра — DDA считает вход в саму стену
|
|||
|
|
перекрытием. Поэтому стены освещаются отдельным проходом: берут максимум от
|
|||
|
|
соседей-нестен, `WALL_LIGHT_PASSES = 3` раза с затуханием. Без этого разделитель
|
|||
|
|
`y=13..15` толщиной три тайла остаётся чёрным изнутри, и его южная грань висит
|
|||
|
|
в пустоте.
|
|||
|
|
|
|||
|
|
**Инвариант:** стена никогда не темнее самого яркого соседа-пола. Иначе тёмная
|
|||
|
|
стена будет пропущена как «ступень 0», и сквозь её силуэт проступит освещённый
|
|||
|
|
пол тайла (tx−1, ty−1), который рисуется раньше по глубине.
|
|||
|
|
|
|||
|
|
### Конус зрения и ИИ
|
|||
|
|
|
|||
|
|
Конус — **чисто игроцкая условность**. ИИ по-прежнему работает по LOS и дальности
|
|||
|
|
оружия, `LaneClearFrom` не изменён. Если завязать выбор целей на видимость,
|
|||
|
|
поменяются все критерии приёмки, а отряд начнёт «слепнуть» из-за поворота ствола.
|
|||
|
|
Чтобы стрельба в темноту не выглядела абсурдом, мишень показывается, если она
|
|||
|
|
освещена **или** если по ней уже работает кто-то из отряда.
|
|||
|
|
|
|||
|
|
## Трассеры
|
|||
|
|
|
|||
|
|
Физика пули не тронута: снаряд летит ровно по проверенному отрезку, правило
|
|||
|
|
«не стрелять сквозь своих» и `FRIENDLY_HITS = 0` не задеты. Менялся только вид.
|
|||
|
|
|
|||
|
|
- **штрих** от `pos` назад по вектору движения на `tracerLength`, но не дальше
|
|||
|
|
точки вылета — `AddBeam` рисует ядро с градиентом цвета и боковым ореолом;
|
|||
|
|
- **вспышка у дула** — `AddGlowDisc` + короткий язык пламени, `muzzleTime`;
|
|||
|
|
- **искра попадания** — своя для стены, цели, убийства;
|
|||
|
|
- **пурпурная искра** = попадание по своему. Это не украшение, а индикатор:
|
|||
|
|
в норме её не должно появиться ни разу.
|
|||
|
|
|
|||
|
|
Всё аддитивно (`AddSat`), поэтому светится и поверх темноты — трассер сам себе
|
|||
|
|
источник света.
|
|||
|
|
|
|||
|
|
## Дуга клинка
|
|||
|
|
|
|||
|
|
`DrawMeleeArcs` в [src/render/fx.cpp](../src/render/fx.cpp). В отличие от
|
|||
|
|
трассеров, дуга читается **прямо из состояния агентов**, а не из журнала событий:
|
|||
|
|
фаза удара живёт десятые доли секунды и попадает в каждый кадр, а привязка к
|
|||
|
|
живой позиции важнее — боец в момент удара едет вперёд рывком.
|
|||
|
|
|
|||
|
|
- **замах** — холодная стальная дуга уводится назад за край сектора и
|
|||
|
|
разгорается. Это телеграф, и он обязан быть виден;
|
|||
|
|
- **удар** — серп из четырёх концентрических дуг: внешний край раскалён,
|
|||
|
|
внутренние тускнеют к рукояти. Одна линия на 480×270 читается палкой, четыре —
|
|||
|
|
взмахом. Хвост — доля пройденной дуги, а не вся: серп, а не кольцо;
|
|||
|
|
- **восстановление** — серп стоит на месте и быстро гаснет.
|
|||
|
|
|
|||
|
|
В изометрии круг — эллипс, поэтому дуга набирается сэмплами **мировых** точек
|
|||
|
|
(`AddWorldArc`), а не экранной окружностью.
|
|||
|
|
|
|||
|
|
Через журнал идут только дискретные события: `MELEE_SWING` (вспышка света),
|
|||
|
|
`MELEE_HIT` (оранжевая искра, сильная отдача камеры — удар всегда в упор),
|
|||
|
|
`MELEE_ABORT` (тусклая серая вспышка: свой влез под лезвие). Клинок дульной
|
|||
|
|
вспышки не даёт, но искры от удара освещают не хуже — иначе весь ближний бой
|
|||
|
|
шёл бы в кромешной темноте.
|
|||
|
|
|
|||
|
|
Подробности механики — [11-melee.md](11-melee.md).
|
|||
|
|
|
|||
|
|
## Канал «симуляция → эффекты»
|
|||
|
|
|
|||
|
|
`sim/events.h`: кольцевой журнал `CombatEventLog`. Симуляция только пишет,
|
|||
|
|
правила не читают, в headless передаётся `nullptr`.
|
|||
|
|
|
|||
|
|
Почему журнал, а не флаг `Agent::fired`: за один кадр рендера проходит **до
|
|||
|
|
`MAX_STEPS_PER_FRAME` шагов симуляции**. Флаг, выставленный на шаге 1 и затёртый
|
|||
|
|
на шаге 2, эффект не увидит. По той же причине `Dread` считает дельты
|
|||
|
|
накопительных счётчиков метрик, а не смотрит на флаги.
|
|||
|
|
|
|||
|
|
## Напряжение (Dread)
|
|||
|
|
|
|||
|
|
Складывается из близости мишени, близости **невидимой** мишени (страшнее всего),
|
|||
|
|
толпы и того, сколько агентов вообще заняты боем. «Занят» — это линия огня у
|
|||
|
|
стрелка или цель в зоне удара у клинка: боец, достающий врага, беспомощным не
|
|||
|
|
выглядит и напряжение снимает так же. Сглаживание асимметричное: страх набегает
|
|||
|
|
быстро (`dreadAttack`), отпускает медленно (`dreadRelease`).
|
|||
|
|
|
|||
|
|
Кормит: глубину виньетки, амплитуду зерна, частоту сердцебиения, отдачу камеры.
|
|||
|
|
Отдача добавляется к `scene.ox/oy` сразу после `scene.Begin` — только там она
|
|||
|
|
согласована и с полом, и со списком сортировки, и с оверлеем.
|
|||
|
|
|
|||
|
|
## Пост-проход
|
|||
|
|
|
|||
|
|
Один слитый цикл по 129600 пикселям: виньетка (статическое поле + таблица кадра),
|
|||
|
|
зерно с амплитудой обратно яркости (в тенях шумит, на свету чисто), давление
|
|||
|
|
сердцебиения. Света на входе **нет** — он уже вплавлен на блите.
|
|||
|
|
|
|||
|
|
Границы: индекс таблицы насыщения равен `128 + канал + шум`, поэтому амплитуда
|
|||
|
|
зерна зажата 127 — иначе в Release пошли бы случайные яркие точки, которые
|
|||
|
|
легко принять за художественный замысел.
|
|||
|
|
|
|||
|
|
## Псевдослучайность
|
|||
|
|
|
|||
|
|
`render/noise.h` — единственное место. Чистые хеш-функции без состояния:
|
|||
|
|
значение зависит только от координаты и номера кадра. Общего генератора нет,
|
|||
|
|
синхронизировать нечего, детерминизм симуляции не задет.
|
|||
|
|
|
|||
|
|
## Цена
|
|||
|
|
|
|||
|
|
Замер на Intel Iris Xe, Release: **1.4–3.5 мс** на кадр при бюджете 16.6 мс,
|
|||
|
|
то есть 8–21 %. Верхняя граница — когда память карты уже разведана и освещённых
|
|||
|
|
тайлов много; fps при этом держится 60. Симуляция — 1.9 мкс. Ни одна из оптимизаций (предзатенённые
|
|||
|
|
спрайты, пересчёт видимости через кадр, уменьшение числа ступеней рампы) не нужна.
|
|||
|
|
|
|||
|
|
Самое дорогое — не пост-проход, а `BlitShaded` сцены. Пропуск полностью тёмных
|
|||
|
|
тайлов это частично компенсирует: в тумане рисуется заметно меньше половины карты.
|
|||
|
|
|
|||
|
|
## Аварийные тумблеры
|
|||
|
|
|
|||
|
|
`F5` — выключить весь хоррор-слой (свет, туман, пост) и вернуть прежний кадр.
|
|||
|
|
`F6` — показать диски обзора. Полезно, когда непонятно, баг это или темнота.
|