Всем привет! Мы небольшой командой уже несколько лет разрабатываем 2D стратегию Norland — симулятор средневекового королевства.

Это вторая статья про 2Д рендеринг разнообразных эффектов в нашей игре. Предыдущую статью можно прочитать здесь. Напомню, что игра двухмерная и разрабатывается на движке Game Maker Studio 2.

Сегодня рассмотрим четыре стихии — воду, огонь, землю и воздух. Какие графические эффекты они скрывают в себе? Давайте узнаем.

Вода

Пока что вода не оказывает глубокого влияния на геймплей в Norland — ну на ней нельзя строить здания — вот и все. Рыбу пока не половить, корабли не построить. Но, тем не менее, её все равно нужно как-то рендерить.

Все началось с разработки мокапа (фейкового скриншота игры, который рисуется в графическом редакторе). В качестве первоначального референса воды я использовал скриншот из игры Graveyard Keeper, обработанный под наш цветокор.

 Graveyard Keeper. Много рыбов.
Graveyard Keeper. Много рыбов.
 Советую функцию "Подобрать цвет" в фотошопе. Именно с помощью нее я адаптировал скриншот Graveyard Keeper под наши цвета
Советую функцию "Подобрать цвет" в фотошопе. Именно с помощью нее я адаптировал скриншот Graveyard Keeper под наши цвета

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

 Мокап воды
Мокап воды

Я примерно понимал, как сделать шейдер для воды — бесшовная текстура с “каустикой” накладывается на тайлы с водой, а волны и рябь имитируются с помощью движения UV координат поверхности воды по особой текстуре с шумом (эффект, более известный как Displacement).

Каустику я нашел на OpenGameArt, а вот где взял текстуру для сдвига UV координат уже не помню — в интернете они фигурируют под разными именами (например bump mapping water texture)

Эффект прост — в каждой точке я беру r и b компоненту цвета Displacement текстуры — это значение сдвига, которое я прибавляю к текстурным координатам каустики. При этом Displacement текстура зациклена и постоянно сдвигается в каком-то направлении (в этом же направлении будет распространяться рябь воды).

 Итоговый эффект на движке игры
Итоговый эффект на движке игры
 Тот же эффект в SHADERed
Тот же эффект в SHADERed

Я воссоздал этот шейдер в SHADERed и при желании вы сможете посмотреть его реализацию и поэкспериментировать с собственными значениями параметров и текстурами. Проект можно скачать здесь.

Код фрагментного шейдера можно увидеть, кликнув дважды на “Simple” в “Pipeline” проекта.
Код фрагментного шейдера можно увидеть, кликнув дважды на “Simple” в “Pipeline” проекта.

Я опускаю некоторые тонкости конкретно нашей реализации — в Noralnd используется 3 тайлсета для воды — берег, дно и пену, из которых собирается итоговый тайл с водой. Поэтому шейдер выглядит сложнее, чем тот, который я представил в SHADERed — например для пены используется своя displacement текстура для более мягкого движения, а также происходит смешивание цветов всех текстур для получения итоговой картинки.

Огонь

Изначально для создания эффекта горящих домов использовались только частицы. Однако я быстро столкнулся с проблемой: чтобы достичь плотного и насыщенного огненного эффекта, требовалось значительное количество частиц. Это, в свою очередь, негативно сказывалось на производительности игры (Game Maker Studio 2.3 не самый производительный движок), особенно когда пожар охватывал половину города. К тому же мне не нравился получившийся эффект — огонь казался не плотным и не особо вписывался в окружение. Результаты этой работы можно увидеть в анонсном трейлере игры.

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

Эффект состоит из нескольких частей:

  • Создание текстуры огня из шума перлина, наложение этой текстуры на здание и анимация полученного результата (простое движение огня снизу вверх);

  • Потемнение текстуры здания и последующее его “растворение” (эффект, более известный, как Dissolve) для полного уничтожения здания, охваченного пожаром;

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

Соблюдайте...
Соблюдайте...
 ...технику безопасности
...технику безопасности

Этот эффект я также перенес в SHADERed.

Земля

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

Пример плохого тайлинга
Пример плохого тайлинга

Изначально у меня возникла идея сгенерировать «кляксы» травы различной формы и цветов, а затем случайным образом раскидать их по всей карте. Меня этот вариант устраивал – он продержался почти полтора года, но игра все ближе к релизу, поэтому пришла пора вернутся к этой задаче для полишинга.

Вот такие предварительно нарисованные «кляксы» случайно распределялись по карт
Вот такие предварительно нарисованные «кляксы» случайно распределялись по карт

Минус клякс был очевиден — чтобы обеспечить равномерное покрытие карты, размером 31,500 x 22,500 нужно разместить очень много таких декалей, они так или иначе будут пересекаться между собой, создавая overdraw (лишняя отрисовка пикселей, которые будут не видны на экране, из-за перекрытия объектов). И то их может быть недостаточно (как оказалось). К тому же форма этих клякс заранее определена, чтобы добавить новые – нужно создавать их вручную в редакторе.

Поэтому я пришел к другому решению — генерировать разнообразный узор поверхности сразу в шейдере (GLSL Fragment).

varying vec2	v_vTexcoord;
varying vec4	v_vColour;
varying vec2	v_vTexelPosition;

uniform float		u_fMidAlpha;
uniform float		u_fBrightAlpha;
uniform float		u_fGrassScale;
uniform float		u_fMidScale;
uniform float		u_fBrightScale;
uniform float		u_fGrassSmooth;
uniform sampler2D	u_sPerlinNoise;
uniform sampler2D	u_sGrass;

void main() {
	float scale = 10000.0;
	
	float threshold 		= 0.5;
	float threshold_step	= 0.02 * u_fGrassSmooth;
	
	float mid_noise = texture2D(u_sPerlinNoise, v_vTexelPosition / vec2(scale, scale * 0.7) * u_fMidScale).r;
	mid_noise = u_fMidAlpha * smoothstep(threshold - threshold_step, threshold + threshold_step, mid_noise);
	
	float bright_noise = texture2D(u_sPerlinNoise, v_vTexelPosition / vec2(scale, -scale * 0.7) * u_fBrightScale).r;
	bright_noise = u_fBrightAlpha * smoothstep(threshold - threshold_step, threshold + threshold_step, bright_noise);
	
	vec3 back_color 	= vec3(134.0, 131.0, 63.0) / 255.0;
	vec3 mid_color		= vec3(113.0, 112.0, 55.0) / 255.0;
	vec3 bright_color	= vec3(157.0, 151.0, 70.0) / 255.0;
	vec3 final_color = mix(back_color, mid_color, mid_noise);
	final_color = mix(final_color, bright_color, (1.0 - mid_noise) * bright_noise);
	
	
	vec3 grass = mix(vec3(1.0), texture2D(u_sGrass, v_vTexelPosition / vec2(scale) * u_fGrassScale).rgb, 0.64);
	gl_FragColor = vec4(final_color * grass, 1.0);
}

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

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

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

Результат до и после.

Воздух

На самом деле здесь раздел не совсем про воздух, а про цветокоррекцию. Но мне нужно было подтянуть это под сложившуюся концепцию природных стихий ;)

LUT (Look-Up-Table) — штука, более известная в среде фотографов и видеографов. Но её назначение везде одинаковое — покрасить изображение определенным образом.

LUT представляет собой текстуру (обычно 512х512 пикселей), в которой зашифрован весь RGB диапазон цветов. Представьте, что это куб, разделенный по слоям. Текстура может выглядеть как-то так:

Нейтральная LUT-текстура, которая не будет менять цвета после применения шейдера цветокоррекции
Нейтральная LUT-текстура, которая не будет менять цвета после применения шейдера цветокоррекции

Идея в том, что каждому RGB цвету соответствует определенный пиксель в этой текстуре (rgb компоненты цвета используются в качестве координат в этой текстуре. Например для красного цвета (#ff0000) это будет правый верхний пиксель в самом первом квадрате).

Работа шейдера цветокоррекции заключается в том, что каждый пиксель изображения меняет свой цвет на цвет соответствующего ему пикселя из LUT-текстуры. Единственное, что потребуется сделать своими руками — подготовить эти самые LUT-текстуры. Но это довольно простой процесс.

Например мы хотим сделать эффект заката солнца с погружением мира в красные тона. Снимаем скриншот из игры, загружаем его в какой-нибудь фото-редактор и начинаем играться с параметрами цвета в различных инструментах (Hue/Saturation, Brightness, Contrast и т. д.). Я, пожалуй, остановлюсь на сдвиге Hue в красную сторону и увеличением насыщенности, но можно не останавливать полет фантазии — например, можно выкрутить saturation только у красного цвета, а все остальное сделать серым (привет, Город грехов).

После этого, нужно применить те же настройки цвета для нейтральной LUT-текстуры. На выходе получим вот такой результат:

LUT-текстура, к которой применили настройки изменения цвета
LUT-текстура, к которой применили настройки изменения цвета

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

varying vec2 v_vTexcoord;
varying vec4 v_vColour;
 
uniform sampler2D   u_sLut;
uniform vec4        uv_sLut;
 
precision highp float;
 
//Number of colours per channel (64 is standard)
#define COL_N 64.0
//Square root of COL_N
#define COL_S 8.0
//Reciprocal of COL_S
#define COL_R 0.125
 
vec3 look_up(vec3 col) {
    vec3 index = clamp(floor(col.rgb * COL_N - 0.5), 0.0, COL_N - 1.0);
    vec2 coord = (index.rg / COL_N + mod(floor(index.b * vec2(1, COL_R)), COL_S)) * COL_R;
 
    return texture2D(u_sLut, coord).rgb;
}
 
void main() {
    vec4 base_color = texture2D(gm_BaseTexture, v_vTexcoord);
 
    gl_FragColor = vec4(look_up(base_color.rgb), 1.0);
}

В Norland мы используем комплект из 5 разных LUT-текстур, каждая из которых соответствует какому-то времени суток — утро, день, вечер, закат, ночь, а также особую текстуру для освещения искусственными источниками света. В каждый момент времени активны какие-то 2 текстуры, которые смешиваются между собой для эффекта плавного изменения цветокора. Например, если утро в игре начинается в 6 часов, а день в 12, то в 8 часов будет применен цветокор, путем смешивания 33:66 двух LUT-текстур (утра и дня).

                      Смена дня и ночи
Смена дня и ночи

В игре есть еще много разных эффектов (искусственное освещение, дождь, кровь, поломка экипировки и т.д.) разной степени сложности, но еще многое предстоит сделать (пока еще ломаю голову над тем, как сделать снег и зиму). Но это когда-нибудь в следующий раз.

Надеюсь, вам было интересно! Спасибо!

Комментарии (0)