Всем привет! Мы небольшой командой уже несколько лет разрабатываем 2D стратегию Norland — симулятор средневекового королевства.
Это вторая статья про 2Д рендеринг разнообразных эффектов в нашей игре. Предыдущую статью можно прочитать здесь. Напомню, что игра двухмерная и разрабатывается на движке Game Maker Studio 2.
Сегодня рассмотрим четыре стихии — воду, огонь, землю и воздух. Какие графические эффекты они скрывают в себе? Давайте узнаем.
Вода
Пока что вода не оказывает глубокого влияния на геймплей в Norland — ну на ней нельзя строить здания — вот и все. Рыбу пока не половить, корабли не построить. Но, тем не менее, её все равно нужно как-то рендерить.
Все началось с разработки мокапа (фейкового скриншота игры, который рисуется в графическом редакторе). В качестве первоначального референса воды я использовал скриншот из игры Graveyard Keeper, обработанный под наш цветокор.
Тайлы береговой линии для нас нарисовали художники, но вот с самой водной гладью у них как-то не задалось. Поэтому пришлось самому придумывать её внешний вид.
Я примерно понимал, как сделать шейдер для воды — бесшовная текстура с “каустикой” накладывается на тайлы с водой, а волны и рябь имитируются с помощью движения UV координат поверхности воды по особой текстуре с шумом (эффект, более известный как Displacement).
Каустику я нашел на OpenGameArt, а вот где взял текстуру для сдвига UV координат уже не помню — в интернете они фигурируют под разными именами (например bump mapping water texture)
Эффект прост — в каждой точке я беру r и b компоненту цвета Displacement текстуры — это значение сдвига, которое я прибавляю к текстурным координатам каустики. При этом Displacement текстура зациклена и постоянно сдвигается в каком-то направлении (в этом же направлении будет распространяться рябь воды).
Я воссоздал этот шейдер в SHADERed и при желании вы сможете посмотреть его реализацию и поэкспериментировать с собственными значениями параметров и текстурами. Проект можно скачать здесь.
Я опускаю некоторые тонкости конкретно нашей реализации — в 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 диапазон цветов. Представьте, что это куб, разделенный по слоям. Текстура может выглядеть как-то так:
Идея в том, что каждому RGB цвету соответствует определенный пиксель в этой текстуре (rgb компоненты цвета используются в качестве координат в этой текстуре. Например для красного цвета (#ff0000) это будет правый верхний пиксель в самом первом квадрате).
Работа шейдера цветокоррекции заключается в том, что каждый пиксель изображения меняет свой цвет на цвет соответствующего ему пикселя из LUT-текстуры. Единственное, что потребуется сделать своими руками — подготовить эти самые LUT-текстуры. Но это довольно простой процесс.
Например мы хотим сделать эффект заката солнца с погружением мира в красные тона. Снимаем скриншот из игры, загружаем его в какой-нибудь фото-редактор и начинаем играться с параметрами цвета в различных инструментах (Hue/Saturation, Brightness, Contrast и т. д.). Я, пожалуй, остановлюсь на сдвиге Hue в красную сторону и увеличением насыщенности, но можно не останавливать полет фантазии — например, можно выкрутить saturation только у красного цвета, а все остальное сделать серым (привет, Город грехов).
После этого, нужно применить те же настройки цвета для нейтральной 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-текстур (утра и дня).
В игре есть еще много разных эффектов (искусственное освещение, дождь, кровь, поломка экипировки и т.д.) разной степени сложности, но еще многое предстоит сделать (пока еще ломаю голову над тем, как сделать снег и зиму). Но это когда-нибудь в следующий раз.
Надеюсь, вам было интересно! Спасибо!