Игра про робота: Эффект секретной зоны

Всё же решил написать небольшую статью-обзор про шейдер, как писал в комментарии.

Постараюсь рассказать как оно работает и можно будет скачать для ГМС2 пример-проект.

Для начала определимся с тем, с чем работаем. Есть у нас игрок (точка) и фон\тайлы\слой, который обычно видимый, но при попадании туда игрока начинает исчезать.

Изначально я хотел добавить простой включил\выключил слой или максимум менять альфу. Но такое мне показалось слишком просто\скучно. Кажется, самим эффектом с альфой я вдохновился увидев в одном видео, где на ГМ собственно делают плавное изменение фона по альфе.

Микро-быстрый экскурс по шейдерным вещам: (описываю своими словами)

sampler2D - простыми словами это текстура.
uniform - это переменная, которую мы передаём в шейдер.
varying - это такие переменные шейдера, которые передаются из вершинного в пиксельный.
Вершинный шейдер обрабатывает вершины, а пиксельный собственно пиксели (выводит).

Но перед написанием шейдера накинем сам тест-проект. Для этого будет достаточно: 1 комнаты, 1 объекта, 3 слоёв, 2 спрайта, 1 тайлсет.

У слоёв в ГМ есть такая особенность — можно включить или выключить их отображение. А вот плавно сменить прозрачность нельзя, что и двигает нас на подвиги с шейдерами т.к. это один из способов реализации полупрозрачности целого слоя. Но как совместить шейдер и слой? Есть функция layer_shader, которая устанавливает собственно шейдер для отображения слоя, НО оно нам не подходит, потому что нельзя передать какие-либо значения в шейдер.

Помогут нам сразу две функции — layer_script_begin и layer_script_end. Они устанавливают функцию, которая будет вызываться при любом рисовании (если точнее — перед рисованием и после).

Один единственный объект

Начнём с объекта. Необходимо будет два события — Create и Step, но для наглядности будем рисовать и курсор в Draw (да, я решил использовать мышку, чтобы играться с визуалом).

Create:

// Найдём слой, который станет растворяться
secrets_layer	= layer_get_id("Secrets");
// А потом ещё и тайлсет
secrets_tilemap = layer_tilemap_get_id(secrets_layer);

// Наша "новая" прозрачность слоя
secrets_alpha = 1;

// Псевдо-игрок
player_x = 0;
player_y = 0;

// Перед самим рисованием
layer_script_begin( secrets_layer,
function()
{
// Пока оставим пустым
});

// После рисования
layer_script_end( secrets_layer, 
function()
{
// Пока оставим пустым
});

Step:

// Псевдоигрок на самом деле мышка
player_x = mouse_x;
player_y = mouse_y;

// Суть кода такова:
// находим тайл в точке игрока и если тайл не пустота(0),
// то снижаем альфу т.е. делаем прозрачным, 
// иначе возвращаем альфу к состоянию непрозрачен.
var tile = tilemap_get_at_pixel(secrets_tilemap, player_x, player_y);
if( tile != 0 )
	secrets_alpha = lerp( secrets_alpha, 0, 0.05 );
else
	secrets_alpha = lerp( secrets_alpha, 1, 0.05 );

lerp — линейная интерпретация. Кажется это настолько же полезная функция, как и sin\cos. Простой код вроде x = lerp (x, target_x, factor) будет двигать x к target_x, сначала резко, но чем ближе, тем медленее т.к. lerp (v0, v1, t) можно описать как v0 + t * (v1 — v0), например.

Надо бы и комнату подготовить

Тут достаточно просто. Есть комната, должен быть тот-самый-объект (назвал его o_secret_logic) на слое Инстансоев\объектов. И собственно мы должны создать слой с тайлами, поставить там несколько для теста. У меня всё это вышло вот так:

JxPNQ8Q

Вернёмся к объекту и шейдеру

Ключевой момент. Давайте скопипастим напишем шейдер. Как вы знаете из пары моих слов и целого крутого поста Хейзера — есть пиксельный и вершинный шейдер. Ещё одно очевидное доказательство того, что ГеймМейкер это ТРИ ДЭ ДВИЖОК — там всё рисуется через растеризацию примитивов т. е. треугольников. (Хорошо, на самом деле есть растеризация и линий).

Растеризация по сути это заполнение 2д пикселей 2д фигур. Да, 2д. Почему? Да потому что все эти выкрутасы в 3д с разным положением камеры, углов взгляда, перспективой перемножаются и в итоге мы получаем такие координаты, что они выглядят как 2д (но 3-тья компонента такие есть). Это как раз из того забавного факта, что 3д игры отображаются на 2д плоскость монитора и по итогу они не 3д. Но что-то меня унесло куда-то.

attribute vec3 in_Position;                  // (x,y,z)
attribute vec4 in_Colour;                    // (r,g,b,a)
attribute vec2 in_TextureCoord;              // (u,v)

varying vec2 v_vTexcoord;
varying vec4 v_vColour;
varying vec4 v_vPosition;

void main()
{
    vec4 object_space_pos = vec4(in_Position, 1.0);
    gl_Position = gm_Matrices[MATRIX_WORLD_VIEW_PROJECTION] * object_space_pos;

// Передаём через varying из вершинного шейдера в пиксельный позицию нашей ВЕРШИНЫ
    v_vPosition = gm_Matrices[MATRIX_WORLD] * object_space_pos;
    v_vColour = in_Colour;
    v_vTexcoord = in_TextureCoord;
}
varying vec2 v_vTexcoord;
varying vec4 v_vColour;
varying vec4 v_vPosition;

// Переменная текстура, которую мы передаём в шейдер
uniform sampler2D u_sMask;

const float c_fMaskSize = 128.0;

void main()
{
	vec4 maskColor = texture2D( u_sMask, v_vPosition.xy/c_fMaskSize );
	vec4 spriteColor = texture2D( gm_BaseTexture, v_vTexcoord );
	
	gl_FragColor = mix(maskColor, spriteColor, 0.0);
}

3M5z2fe

3fcis57

Вот такой вот результат будет. Это промежуточный вариант, чтобы показать зачем нужен v_vPosition. А именно — через него мы делаем отображение «позиция в мире = текстурные координаты * множитель».

Хорошо, что вас томить — давайте просто доделаем этот пример.

Возвращаемся в объект и в событии создания добавляем получение индексов uniform-ов шейдера:

sh_uniform_progress		= shader_get_uniform(shader_secret, "u_fProgress");
sh_uniform_position		= shader_get_uniform(shader_secret, "u_vPlayerPosition");
sh_uniform_texture		= shader_get_sampler_index(shader_secret, "u_sMask");

// Заполним наши функции рисования слоя:
// Before draw
layer_script_begin( secrets_layer,
function()
{
	if( event_type == ev_draw && event_number == 0 )
	{
		with(o_secret_logic)
		{
			if( secrets_alpha < 1 )
			{
                                // Установим шейдер текущим
				shader_set(shader_secret);
                                // Установим пременные шейдера для текущего рисования
				shader_set_uniform_f(sh_uniform_progress, secrets_alpha);
				shader_set_uniform_f(sh_uniform_position, player_x, player_y );
                                // Данной функцией мы устанавливаем текстуру в uniform
				texture_set_stage(sh_uniform_texture, sprite_get_texture(s_cloud_texture, 0));
				gpu_set_tex_repeat_ext(sh_uniform_texture, true);
			}
		}
	}
});

// After draw
layer_script_end( secrets_layer, 
function()
{
	if( event_type == ev_draw && event_number == 0 )
		with(o_secret_logic) 
			if( secrets_alpha < 1 )
				shader_reset();
});

В layer_script_* задаются функции, внутри которых есть event_type == ev_draw и event_number == 0 что же это такое? Переданные функции будет работать на все события рисования, а нам нужно только в обычном рисовании. Поэтому мы проверяем, что текущее событие это класс событий рисования (event_type) и что это обычное-классическое рисование (event_number=0), а не draw end, например.

Мы можем даже добавить проверку на view_current и включать шейдер лишь для конкретного включенного вида и получим вот такое:

YrzVcEc
varying vec2 v_vTexcoord;
varying vec4 v_vColour;
varying vec4 v_vPosition;

uniform vec2 u_vPlayerPosition;
uniform float u_fProgress;
uniform sampler2D u_sMask;

const float c_fVisionRadius = 128.0;
const float c_fMaskSize = 128.0;

void main()
{
        // Пиксель нашей переменной-текстуры, на самом деле нужен только R канал
	vec4 maskColor = texture2D( u_sMask, v_vPosition.xy/c_fMaskSize );
        
        // Цвет пикселя из спрайтатайлсета
	vec4 spriteColor = texture2D( gm_BaseTexture, v_vTexcoord );
	
	// находим вектор от текущего ПИКСЕЛЯ*** до позиции игрока, которую мы передали
	vec2 position	= (v_vPosition.xy-u_vPlayerPosition);
        // Основываясь на Прогрессе (альфа) и расстоянии между пикселем и игроком
        // создаём переменную, которая хранит значение от 0 до 1.0.
	float disolve	= smoothstep( 0.0, c_fVisionRadius * (1.0-u_fProgress), distance(v_vPosition.xy, u_vPlayerPosition));
        // Тут мы уже добавляем нашу маску-облака и disolve в степени 3, чтобы переход был более резкий.
	float blend     = smoothstep( 0.0, maskColor.r, pow(disolve,3.0) );
	
        // Смешаем чёрно-прозрачный и изначальный цвет тайла
	gl_FragColor = mix( vec4(0.0), spriteColor, blend);
	
	// Alpha test. Если альфа очень небольшая, то просто не рисовать пиксель.
	if( gl_FragColor.a < 0.01 ) discard;
}

***ПИКСЛЕЯ. Именно так. Из вершинного шейдера передали позицию вершины, но обрабатываем мы пиксель. Здесь вступает в силу интерполяция т. е. когда происходит растеризация, то v_vPosition хранит интерполированное значение между несколькими вершинами. Конечно я боюсь вам наврать и вдруг оно работает совсем не так, но как минимум по наблюдениям оно именно так. Например, так же работают и текстурные координаты. Допустим мы нарисуем на весь экран прямоугольник, его текстурные координаты будут в пределах от 0 до 1 по X и Y. Как получается так, что на конкретный пиксель экрана рисуется нужный пиксель из текстуры? Тут как раз интерпретируется значение между 0 и 1, ведь только такие текстурные координаты мы задали вершинам.

Думаю не особо понятно про disolve, поэтому вот скриншот, если подать как выходной цвет (gl_FragColor) значение disolve:

AR8fUVV

А это если подать на выходной цвет blend:

mTtXZN7

И как полнейший итог вот так оно работает в свою силу:

zQSciLh


На этом всё. Возможно, я что-то упустил. Ах да, ссылку на скачивание. Чтобы оно не затерялось решил выложить это на итч:

https://darkdes.itch.io/secret-area-shader-gamemakerstudio2

Спасибо за внимание!