Свет, который знает форму: карты нормалей для пререндеренных спрайтов
В 2D-стратегии танк — это картинка с запечённым в неё солнцем. Когда ночью рядом падает снаряд, картинка не знает, какая её сторона повёрнута к взрыву. В этом уроке — тот самый кусочек данных, который это исправляет, карта нормалей, и то, как мы встроили её в Iron Sector.
- листов спрайтов с картами нормалей
- 135листов спрайтов с картами нормалей
- кадра, у каждого свои нормали
- 55 204кадра, у каждого свои нормали
- разрешения у страниц нормалей
- ½разрешения у страниц нормалей
- на кадр при 50 освещённых спрайтах
- +0,5 мсна кадр при 50 освещённых спрайтах
Спрайт — это картинка
В 2D-стратегии танк — не 3D-модель. Это картинка, один раз отрендеренная с фиксированной камеры и сложенная в атлас. Каждый кадр — просто сетка цветов, и солнечный свет запечён в эти цвета в момент создания спрайта.
Это быстро и выглядит правильно — пока в сцене не начинает светиться что-то ещё: вспышка выстрела, взрыв, горящий ночью дом. Картинка не знает, куда смотрят её поверхности, поэтому максимум, что может игра, — сделать весь спрайт ярче. Выглядит как наклейка.


Что такое нормаль
Нормаль — это стрелка единичной длины, которая стоит перпендикулярно поверхности и показывает, куда та повёрнута. Чтобы осветить поверхность, рендер сравнивает эту стрелку со второй — направленной на источник света.
В 1760 году Иоганн Ламберт описал правило, которым игры пользуются до сих пор: матовая поверхность получает свет пропорционально косинусу угла между двумя стрелками. А косинус — это просто их скалярное произведение: x на x, y на y, z на z, и сложить. Грань, повёрнутая прямо к свету, получает его весь; повёрнутая ребром или прочь — ничего.
brightness ∝ cos θ = n · l n · l = nx·lx + ny·ly + nz·lz
| Угол к свету | Доля света |
|---|---|
| 0° | 1.00 |
| 45° | 0.71 |
| 60° | 0.50 |
| ≥ 90° | 0 |
Карты нормалей в играх
В 1978 году Джеймс Блинн показал, что мелкий рельеф можно подделать, изгибая нормали поверхности, без всякой новой геометрии. К 2004 году, с Doom 3 и Half-Life 2, это стало стандартом: простая модель носит текстуру нормалей, запечённую с гораздо более детальной, и заклёпки с кирпичами ловят свет.
2D-игры переняли идею. В Sprite Lamp художник рисует персонажа, освещённого с нескольких сторон, и программа выводит из этого карту нормалей. Dead Cells рендерит персонажей из 3D-моделей и к каждому кадру выгружает карту нормалей. Iron Sector ближе всего к этому случаю: наши спрайты рендерятся из 3D-моделей, поэтому нормали не рисуются и не угадываются. Они измеряются.
Как нормаль хранится в картинке
У нормали три числа, у картинки три канала, поэтому карта нормалей хранит стрелку как цвет. Красный — насколько поверхность смотрит вправо, зелёный — вверх, синий — на камеру.
Каждое значение лежит от −1 до 1, поэтому его сжимают в диапазон цвета: делят пополам и прибавляют половину. Отсюда фиолетовый цвет карт нормалей: поверхность, смотрящая прямо в камеру, — это (128, 128, 255). Чтобы прочитать пиксель обратно, делим на 255, удваиваем и вычитаем единицу.
colour = n × 0.5 + 0.5 n = colour × 2 − 1 (218, 83, 205) → (0.71, −0.35, 0.61)


Откуда берутся наши нормали
Модели Iron Sector пишутся кодом и превращаются в воксели — кубики примерно по два сантиметра. У вокселя на поверхности есть хотя бы один пустой сосед. Чтобы найти его нормаль, генератор смотрит на всех 26 соседей: каждый заполненный отталкивает стрелку от себя, диагональные — чуть слабее. Сумма толчков, приведённая к единичной длине, и даёт стрелку наружу из материала.
На плоской крыше снизу всё заполнено, сверху пусто — стрелка смотрит строго вверх; на круглой башне стрелки плавно расходятся веером. Тот же проход, что пишет цвет пикселя, пишет в тот же пиксель и нормаль, поэтому карта нормалей совпадает со спрайтом точно. Для игры это 135 листов спрайтов и 55 204 кадра, собранные рядом с уже существующими атласами.
n = normalize( −Σ filled(k) · dₖ / |dₖ| ) over 26 neighbours

Солнце, которое уже есть
В каждом спрайте уже есть свет: солнце сверху слева, запечённое при рендере, — 30 % рассеянного света и 70 % солнца по тому же скалярному произведению.
Сначала мы попробовали путь из учебника: хранить цвета без света и переосвещать всё во время игры. Переосвещённый танк ошибался до 32 шагов из 255 при нашем пороге в 3 — мягкую тень на земле и детали мельче пикселя из карт не восстановить. Поэтому утверждённая дневная картинка остаётся как есть, а новый свет добавляется поверх. Ровно так поступает и настоящая вспышка.
intensity = 0.30 + 0.70 · max(0, n · L) L = (−0.55, 0.30, 0.78)
Уравнение одного источника
Добавленный цвет — это собственный цвет спрайта, умноженный на силу k, на цвет источника, на множитель направления и на множитель расстояния. Множитель направления — скалярное произведение с wrap lighting: прибавляем 0,2 перед делением, и свет немного заходит за края, как на пыльной крашеной стали. Множитель расстояния плавно спадает от единицы до нуля на радиусе источника.
Возьмём пиксель башни ночью и вспышку на половине её радиуса. Справа n · l = 0,71, после wrap — 0,76; затухание 0,56; при k = 2,2 пиксель становится ярче на 93 %. Слева n · l отрицательно, и пиксель не получает ничего. Без нормалей обе стороны получают одинаковые 124 % — и танк выглядит плоским.
added = colour × k × light × wrap(n · l) × falloff(d / r) wrap(x) = max(0, (x + 0.2) / 1.2) falloff(q) = (1 − q²)² k: day 0.35 · dawn 1.2 · night 2.2




Два пространства, одна камера
Нормали хранятся в экранном пространстве — так, как их видит камера. Источники света живут в мире: точка на земле и высота над ней. Наша камера зафиксирована навсегда — 45° по кругу и 30° вниз, поэтому переход — это один поворот на 30°.
Для каждого пикселя шейдер берёт вектор к источнику, наклоняет его на угол камеры и только потом считает скалярное произведение. Огонь в 10 единицах над землёй и в 10 единицах перед танком превращается в стрелку, которая смотрит в основном в экран и немного сверху: он освещает обращённые к нам грани и немного крышу.
v = ( dx, dz·cos 30° − dy·sin 30°, dy·cos 30° + dz·sin 30° )
В игре
Ночью «Тигр» стреляет из главного орудия. Вспышка длится несколько кадров, но её свет достаёт в шесть раз дальше видимого ореола — в темноте замечаешь именно свет. Без нормалей вспышка рисует яркий круг одинаково на земле и на танке. С нормалями загораются лобовой лист и маска орудия, а корма остаётся тёмной. Днём та же вспышка почти незаметна — так и должно быть.
Взрывы — вот где нормали окупаются. Снаряд слева освещает левый борт, снаряд справа переносит свет на другую сторону. В артналёте каждый спрайт берёт восемь самых сильных источников и складывает их, поэтому танк может быть освещён с двух-трёх сторон сразу, каждая — своим цветом.







Свет, который останавливается у стены
Решить, где рисовать свет, — половина работы. Нарисованный поверх всех спрайтов, свет танка за домом пробивал стену насквозь. Нарисованный под ночным фильтром, он упирался в тёмно-синий цвет ночи: красный обрезался до трети, и огонь выходил коричневым.
Ответом стала отдельная картинка света. Рендер рисует сцену второй раз, в том же порядке, в отдельную текстуру: освещённые спрайты — чистым светом, все остальные — чёрным силуэтом, так что дом впереди закрывает танк за собой. Потом эта картинка добавляется поверх ночной цветокоррекции, и огонь остаётся тёплым. Одно честное ограничение остаётся: у света пока нет теней.
Сколько это стоит
Карта нормалей такого же размера, как картинка, к которой она относится, но форма меняется медленнее цвета, поэтому нормали хранятся в половинном разрешении. После масштабирования обратно разница — примерно один шаг из 255. Сцене нужно на десятую-четверть больше памяти, а на быстрой видеокарте пятьдесят освещённых спрайтов добавляют около половины миллисекунды на кадр.
Вся система отключаемая. С выключенным освещением не меняется ни один пиксель и не загружается ни одна карта нормалей. «Средне» освещает 50 спрайтов по 3 источника, «Высоко» — 150 спрайтов по 8 источников.
Итог
Три числа в пикселе, одно скалярное произведение на источник и аккуратный выбор того, где рисовать свет. Спрайты по-прежнему картинки, один раз отрендеренные с фиксированной камеры, но теперь каждый пиксель знает, куда он смотрит. И когда в темноте что-то горит, танк поворачивает к свету свою броню.