Найти в Дзене
ZDG

Ретро-лайтмап для 2D-игры

Оглавление

Я постоянно вынашиваю идеи каких-то простых 2D-игр или их ремейков. И большая часть из них существенно выиграла бы с наличием специфического освещения.

К примеру: факел, висящий на стене в подземелье, освещает стену вокруг себя. Вот я симулировал стену без эффекта освещения (слева) и с эффектом (справа):

Вариант с освещением выглядит гораздо приятнее. Такой же эффект можно применять во многих других случаях: когда стреляют огненными сгустками, светят фонариком и т.д.

Почему не OpenGL

В современных 2D-играх такие эффекты практически поголовно делаются с помощью ускорителей графики, но я не хочу.

Причина простая. Я не умею в OpenGL Во мне живёт дух старой школы. Вот, например, игра Quake работала без всяких ускорителей на уже очень древнем процессоре. А ведь её сложность существенно выше какого-нибудь Арканоида. Поэтому для простой 2D-игры мощности современного процессора и современной видеокарты должно быть более чем достаточно.

Что такое Light Map?

Лайтмап это буквально световая карта. Предположим, у нас есть игровой уровень, который надо осветить. Это значит, что для каждого экранного пиксела нужно вычислить его освещённость, исходя из количества, расположения, яркости и оттенков источников света.

Такие вычисления, если делать их в каждом кадре для каждого пиксела, заняли бы очень много времени. Поэтому их делают заранее. С учётом каждого источника света составляется карта, в которой указано, какой пиксел должен иметь какую яркость. Затем эта карта накладывается на реальные пикселы текстур. Цвет каждого пиксела теперь вычисляется (точнее, корректируется) за условно одну операцию умножения на значение в карте.

У такого подхода есть недостатки. В игре Quake каждый полигон имел назначенную ему текстуру и также назначенный ему просчитанный лайтмап. Но если одну и ту же текстуру можно использовать на тысячах полигонов, то лайтмапы для каждого из них уникальны. И их нужно как-то хранить.

В результате был найден компромисс: разрешение лайтмапов было уменьшено в несколько раз. Именно поэтому на многих уровнях Quake можно увидеть ступенчатые края теней.

-2

Вторая проблема это статичность лайтмапов. Ведь они вычислены один раз для одного освещения. Если источники света сдвинутся или изменятся, лайтмап нужно пересчитывать по новой.

Ретро-лайтмапы

К счастью, такие сложности практически отсутствуют в ретро-играх. Реалистичного освещения там нет, а то, которое есть, рисуется художниками, а не процедурно.

-3

Так что задача сводится только лишь к отдельным акцентам на освещении, вроде горящих факелов на стенах или летящих огненных сгустков.

Кроме того, не требуется никакая особенная реалистичность. Достаточно, чтобы в центре источника света присутствовал идеализированный светлый круг.

Для этого рисуется полупрозрачная текстура светлого круга и накладывается поверх текстуры уровня:

-4

А поверх накладываем объект, который якобы излучает свет:

-5

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

Но есть нюанс

Я не пользуюсь игровыми движками по той же причине, по которой не пользуюсь OpenGL. Для простых проектов это сверх-избыточно.

Значит, я должен самостоятельно смешать RGBA-значения пикселов из двух спрайтов. Это уже делалось в проекте Генетической Моны Лизы:

Но лайтмап это не RGB-текстура, а именно карта интенсивностей, поэтому вычисления будут другие.

  1. Пиксел имеет значения R, G, B. Можно считать, что каждое значение это количество излучаемого пикселом света в данном цветовом канале.
  2. Чтобы "осветить" пиксел, нужно умножить его значения RGB на какое-то число. Тогда количество излучаемого света в каждом канале станет больше в это число раз.
  3. В лайтмапе надо хранить именно те числа, на которые надо умножать RGB-каналы.

Таким образом, вычисление финального количества света в каждом канале сводится к одной операции:

k = lightmap[x, y];
R *= k;
G *= k;
B *= k;

Правда, одна операция всё-таки не получится, что увидим позже.

Пока создадим сам лайтмап с профилем интенсивности. Что это такое? Свет имеет наибольшую интенсивность в центре и ослабляется к краям. График этого ослабления может быть разный – в виде полусферы, ломаной линии или даже волны, то есть можно получать программно самые разные профили, в том числе и работающие наоборот, то есть с самой низкой интенсивностью в центре и самой высокой на краях.

-6

На данный момент я возьму косинус. Это должно быть похоже на реальное пятно света.

-7

Структура LightMap хранит указатель на выделенную память для значений и ширину текстуры (она же и высота). Память заполняется значениями, которые рассчитываются пропорционально косинусу расстояния пиксела от центра текстуры, и задают наибольшую интенсивность (255) в центре и наименьшую (0) на краю.

О вещественных числах

Было бы удобнее, да и вообще так принято, хранить значения лайтмапа в вещественных числах от 0 до 1. Но те замеры, которые я успел сделать, показали, что целочисленная арифметика работает всё-таки быстрее. Кроме того, тип float занимает в 4 раза больше памяти. Поэтому держим в уме такую пропорцию: целочисленный диапазон 0 – 255 это вещественный 0.0 – 1.0.

Вывод лайтмапа на экран

-8

Как обычно, я использую графическую библиотеку SDL2. Здесь делается запись байтов непосредственно в память "поверхности для рисования" (SDL_Surface), которая затем выводится на экран. dst[0] это B, dst[1] это G, dst[2] это R, а пиксел занимает 4 байта.

Вот что получается:

-9

Сейчас лайтмап никак не влияет на пикселы экранной поверхности и просто записывает в память собственные значения от 0 до 255, но видно, что нужный профиль интенсивности получен.

Наложение лайтмапа на текстуру

Вообще говоря, с помощью лайтмапа можно задавать как освещение, так и затенение. Тогда значение 0 будет означать полную темноту, а значение 255 – максимальную яркость.

Но в моём варианте лайтмап работает не как непосредственно освещение, а как его регулятор.

1. Получение эффективной интенсивности

Я добавил параметр intensity (интенсивность), который задаёт силу освещения при применении лайтмапа. Его минимальное значение 1 означает, что при умножении на 1 значение пиксела никак не изменится. А максимальное значение 255 означает, что значение вырастет в 255 раз. Такой диапазон выбран исходя из того, что минимальное значение пиксела 1 при умножении на 255 даст максимально возможное 255.

К примеру, я задал интенсивность 4. Значит, все пикселы должны увеличить яркость в 4 раза, но тут и вступает в дело профиль лайтмапа.

Если соответствующая позиция в лайтмапе равна 0, то результирующая интенсивность должна быть 1 (то есть ничего не меняется). Если же позиция в лайтмапе равна 255, то результирующая интенсивность должна быть 4. Имеем пропорцию:

0..255→1 + (0..intensity-1)

Итоговая формула интенсивности:

v = 1 + lightmap[x, y] * (intensity - 1) / 255;

Это даст диапазон вещественных значений от 1 до intensity с поправкой на lightmap. Например: интенсивность равна 4 (увеличить яркость в 4 раза). Значение lightmap в этой точке равно, например, 32. Получаем:

1 + 32 * (4 - 1) / 255 = 1.37

Значит, реально в этой точке яркость увеличится не в 4, а только в 1.37 раза.

Так как я использую целочисленную арифметику, то прибавлять 1 и делить на 255 буду не сразу, чтобы не потерять точность. Итого, предварительная эффективная интенсивность:

v = lightmap[x, y] * (intensity - 1);

2. Умножение значения канала на эффективную интенсивность

Возьмем значение R и умножим на полученную ранее предварительную эффективную интенсивность v:

R = R * v

Теперь можно применить ранее отложенное деление на 255:

R = R * v / 255

И отложенное добавление 1, где 1 превратится в R:

R = R * (1 + v / 255) → R = R + R * v / 255

Далее я загрублю формулу и вместо деления на 255 сделаю деление на 256, что позволит заменить его сдвигом вправо:

R = R + ((R * v) >> 8)

То же самое будет сделано для компонентов G и B, и вот результат:

-10

Как это выглядит:

-11

Похоже на правду, но вылезли странные зелёные пятна. Они возникли из-за переполнения в некоторых значениях RGB. Для таких значений нужно делать лимитирование (clamping). Если по-простому, то присваивать 255, когда значение больше чем 255:

-12

Артефакты исчезли:

-13

И можно посмотреть, что будет, если задать большую интенсивность:

-14

3. Сложение интенсивностей

Если наложить два пересекающихся лайтмапа на одну текстуру, то получим такой результат:

-15

Там, где лайтмапы пересекаются, свечение усиливается. Это ожидаемо, но происходит не совсем правильно. При наложении одного лайтмапа значения пикселов умножаются на 6, затем при наложении второго они ещё раз умножаются на 6. В итоге яркость увеличивается в 6*6=36 раз, а должна увеличиться в 6+6=12 раз.

Это довольно серьёзное препятствие. Одно из решений это предварительно сложить все участвующие лайтмапы в один большой комбинированный лайтмап, а потом уже накладывать его. Другое решение это запоминать оригинальное значение пиксела и при очередном наложении лайтмапа использовать для расчётов не текущее, а оригинальное значение. Я так и сделал, но понадобилась ещё одна текстура, которая является исходником-оригиналом для экранной (в коде обозначена как src):

-16

Результат: правильное (ну или визуально более естественное) сложение яркостей лайтмапов.

-17

А большего мне и не надо!

-18

Быстродействие и выводы

Пришлось дополнительно заняться проблемой лимитирования значений RGB вида:

R = d > 255 ? 255 : d

Тернарный оператор позволяет избежать переходов и должен работать быстрее традиционного if..else, но в данном случае выигрыша в скорости не дал. Думаю, компилятор оптимизировал и тот и другой случай, так что быстрее уже не будет.

В качестве альтернативы я сделал LUT-таблицу (Look Up Table, справочная таблица), которая содержит готовые результаты сравнения. Максимальным значением переполнения может быть 255*255, поэтому таблица имеет такой размер. В первых 256 элементах записаны значения 0..255, в остальных – 255.

Теперь значение берётся из таблицы:

R = lut[d]

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

В итоге 5000 лайтмапов обрабатываются за 0.6 секунды. Для частоты 60 кадров в секунду получается, что можно успеть вывести лишь около 100 лайтмапов в одном кадре.

Немного, но с другой стороны, надо ли столько? К тому же использованное разрешение лайтмапа 256*256, а это, на минуточку, размер всего экрана БК-0010. Более мелкие размеры лайтмапов приведут к очень существенному ускорению.

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

-19

Также можно накладывать лайтмапы через одну строку, либо выводить часть из них в чётных кадрах, а часть в нечётных. Тогда они будут мерцать с частотой 60 Гц, но и это можно использовать как специальный эффект.

Наконец, можно применять специальные SIMD-инструкции процессора (Single Instruction, Multiple Data), с помощью которых можно обрабатывать данные сразу пакетно. Но так как они процессорно-зависимы, то оставим это на потом.

Ну и остался ещё такой вариант, как многопоточность.

В общем, особых проблем для дальнейшего применения ретро-лайтмапов в разумных пределах пока что не видно.