DSHINTERACTIVE
Уроки
РЕНДЕРИНГСредний11:52Iron Sector

Свет, который знает форму: карты нормалей для пререндеренных спрайтов

В 2D-стратегии танк — это картинка с запечённым в неё солнцем. Когда ночью рядом падает снаряд, картинка не знает, какая её сторона повёрнута к взрыву. В этом уроке — тот самый кусочек данных, который это исправляет, карта нормалей, и то, как мы встроили её в Iron Sector.

листов спрайтов с картами нормалей
135
листов спрайтов с картами нормалей
кадра, у каждого свои нормали
55 204
кадра, у каждого свои нормали
разрешения у страниц нормалей
½
разрешения у страниц нормалей
на кадр при 50 освещённых спрайтах
+0,5 мс
на кадр при 50 освещённых спрайтах
01

Спрайт — это картинка

В 2D-стратегии танк — не 3D-модель. Это картинка, один раз отрендеренная с фиксированной камеры и сложенная в атлас. Каждый кадр — просто сетка цветов, и солнечный свет запечён в эти цвета в момент создания спрайта.

Это быстро и выглядит правильно — пока в сцене не начинает светиться что-то ещё: вспышка выстрела, взрыв, горящий ночью дом. Картинка не знает, куда смотрят её поверхности, поэтому максимум, что может игра, — сделать весь спрайт ярче. Выглядит как наклейка.

С нормалями — С нормалями
С нормалями — Без нормалей
Без нормалейС нормалями
Потяните, чтобы сравнить
02

Что такое нормаль

Нормаль — это стрелка единичной длины, которая стоит перпендикулярно поверхности и показывает, куда та повёрнута. Чтобы осветить поверхность, рендер сравнивает эту стрелку со второй — направленной на источник света.

В 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
03

Карты нормалей в играх

В 1978 году Джеймс Блинн показал, что мелкий рельеф можно подделать, изгибая нормали поверхности, без всякой новой геометрии. К 2004 году, с Doom 3 и Half-Life 2, это стало стандартом: простая модель носит текстуру нормалей, запечённую с гораздо более детальной, и заклёпки с кирпичами ловят свет.

2D-игры переняли идею. В Sprite Lamp художник рисует персонажа, освещённого с нескольких сторон, и программа выводит из этого карту нормалей. Dead Cells рендерит персонажей из 3D-моделей и к каждому кадру выгружает карту нормалей. Iron Sector ближе всего к этому случаю: наши спрайты рендерятся из 3D-моделей, поэтому нормали не рисуются и не угадываются. Они измеряются.

04

Как нормаль хранится в картинке

У нормали три числа, у картинки три канала, поэтому карта нормалей хранит стрелку как цвет. Красный — насколько поверхность смотрит вправо, зелёный — вверх, синий — на камеру.

Каждое значение лежит от −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)
Цветной кадр «Тигра» и его кадр нормалей — из одного прохода рендера.
Цветной кадр «Тигра» и его кадр нормалей — из одного прохода рендера.
Один пиксель башни: в файле (218, 83, 205) → ÷255 → ×2 − 1 → стрелка (0,71; −0,35; 0,61).
Один пиксель башни: в файле (218, 83, 205) → ÷255 → ×2 − 1 → стрелка (0,71; −0,35; 0,61).
05

Откуда берутся наши нормали

Модели Iron Sector пишутся кодом и превращаются в воксели — кубики примерно по два сантиметра. У вокселя на поверхности есть хотя бы один пустой сосед. Чтобы найти его нормаль, генератор смотрит на всех 26 соседей: каждый заполненный отталкивает стрелку от себя, диагональные — чуть слабее. Сумма толчков, приведённая к единичной длине, и даёт стрелку наружу из материала.

На плоской крыше снизу всё заполнено, сверху пусто — стрелка смотрит строго вверх; на круглой башне стрелки плавно расходятся веером. Тот же проход, что пишет цвет пикселя, пишет в тот же пиксель и нормаль, поэтому карта нормалей совпадает со спрайтом точно. Для игры это 135 листов спрайтов и 55 204 кадра, собранные рядом с уже существующими атласами.

n = normalize( −Σ filled(k) · dₖ / |dₖ| )   over 26 neighbours
Одна модель с разных направлений: цвет и нормали, кадр за кадром.
Одна модель с разных направлений: цвет и нормали, кадр за кадром.
06

Солнце, которое уже есть

В каждом спрайте уже есть свет: солнце сверху слева, запечённое при рендере, — 30 % рассеянного света и 70 % солнца по тому же скалярному произведению.

Сначала мы попробовали путь из учебника: хранить цвета без света и переосвещать всё во время игры. Переосвещённый танк ошибался до 32 шагов из 255 при нашем пороге в 3 — мягкую тень на земле и детали мельче пикселя из карт не восстановить. Поэтому утверждённая дневная картинка остаётся как есть, а новый свет добавляется поверх. Ровно так поступает и настоящая вспышка.

intensity = 0.30 + 0.70 · max(0, n · L)
L = (−0.55, 0.30, 0.78)
07

Уравнение одного источника

Добавленный цвет — это собственный цвет спрайта, умноженный на силу 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
Снаряд падает слева. С нормалями загораются только грани, повёрнутые к нему. — С нормалями
Снаряд падает слева. С нормалями загораются только грани, повёрнутые к нему. — Без нормалей
Без нормалейС нормалями
Потяните, чтобы сравнить
Снаряд падает слева. С нормалями загораются только грани, повёрнутые к нему.
Тот же снаряд справа — свет переходит на другую сторону. — С нормалями
Тот же снаряд справа — свет переходит на другую сторону. — Без нормалей
Без нормалейС нормалями
Потяните, чтобы сравнить
Тот же снаряд справа — свет переходит на другую сторону.
08

Два пространства, одна камера

Нормали хранятся в экранном пространстве — так, как их видит камера. Источники света живут в мире: точка на земле и высота над ней. Наша камера зафиксирована навсегда — 45° по кругу и 30° вниз, поэтому переход — это один поворот на 30°.

Для каждого пикселя шейдер берёт вектор к источнику, наклоняет его на угол камеры и только потом считает скалярное произведение. Огонь в 10 единицах над землёй и в 10 единицах перед танком превращается в стрелку, которая смотрит в основном в экран и немного сверху: он освещает обращённые к нам грани и немного крышу.

v = ( dx,  dz·cos 30° − dy·sin 30°,  dy·cos 30° + dz·sin 30° )
09

В игре

Ночью «Тигр» стреляет из главного орудия. Вспышка длится несколько кадров, но её свет достаёт в шесть раз дальше видимого ореола — в темноте замечаешь именно свет. Без нормалей вспышка рисует яркий круг одинаково на земле и на танке. С нормалями загораются лобовой лист и маска орудия, а корма остаётся тёмной. Днём та же вспышка почти незаметна — так и должно быть.

Взрывы — вот где нормали окупаются. Снаряд слева освещает левый борт, снаряд справа переносит свет на другую сторону. В артналёте каждый спрайт берёт восемь самых сильных источников и складывает их, поэтому танк может быть освещён с двух-трёх сторон сразу, каждая — своим цветом.

ДеньДень
РассветРассвет
НочьНочь
Тот же выстрел днём, на рассвете и ночью. Сила k растёт, когда сцена темнеет.
С нормалями — С нормалями
С нормалями — Без нормалей
Без нормалейС нормалями
Потяните, чтобы сравнить
Горящий остов вплотную к танку освещает его почти равномерно — так и задумано. — С нормалями
Горящий остов вплотную к танку освещает его почти равномерно — так и задумано. — Без нормалей
Без нормалейС нормалями
Потяните, чтобы сравнить
Горящий остов вплотную к танку освещает его почти равномерно — так и задумано.
10

Свет, который останавливается у стены

Решить, где рисовать свет, — половина работы. Нарисованный поверх всех спрайтов, свет танка за домом пробивал стену насквозь. Нарисованный под ночным фильтром, он упирался в тёмно-синий цвет ночи: красный обрезался до трети, и огонь выходил коричневым.

Ответом стала отдельная картинка света. Рендер рисует сцену второй раз, в том же порядке, в отдельную текстуру: освещённые спрайты — чистым светом, все остальные — чёрным силуэтом, так что дом впереди закрывает танк за собой. Потом эта картинка добавляется поверх ночной цветокоррекции, и огонь остаётся тёплым. Одно честное ограничение остаётся: у света пока нет теней.

11

Сколько это стоит

Карта нормалей такого же размера, как картинка, к которой она относится, но форма меняется медленнее цвета, поэтому нормали хранятся в половинном разрешении. После масштабирования обратно разница — примерно один шаг из 255. Сцене нужно на десятую-четверть больше памяти, а на быстрой видеокарте пятьдесят освещённых спрайтов добавляют около половины миллисекунды на кадр.

Вся система отключаемая. С выключенным освещением не меняется ни один пиксель и не загружается ни одна карта нормалей. «Средне» освещает 50 спрайтов по 3 источника, «Высоко» — 150 спрайтов по 8 источников.

12

Итог

Три числа в пикселе, одно скалярное произведение на источник и аккуратный выбор того, где рисовать свет. Спрайты по-прежнему картинки, один раз отрендеренные с фиксированной камеры, но теперь каждый пиксель знает, куда он смотрит. И когда в темноте что-то горит, танк поворачивает к свету свою броню.

Новые уроки

Новые уроки на почту

Одно письмо, когда выходит новый урок: видео и текстовая версия.

Адрес используем только для этих писем. Без спама.

Новые уроки на YouTube.

Девлоги, разборы и пошаговые уроки от команды DSH Interactive.

Подписаться на YouTube