D:\sideБлогGameMaker Studio: шейдеры, введение

⚠️ Обращайте внимание на даты.
Этот блог больше не ведётся с 17 января 2023, и на тот момент с написания этой страницы (03.07.2013) прошло 9 лет.

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

Итак, шейдеры. Их релиз обещан в GameMaker Studio 1.2, но уже сейчас можно скачать бету 1.1.1058 и попробовать их в деле. Но прежде чем пробовать, нужно понять, что это вообще такое, у многих проблемы уже с этим.

Для начала — разрыв шаблона для многих. Когда смотрите на видеокарту, в первую очередь смотреть нужно вовсе не на объём памяти, а на модель графического чипа. Так вышло, что я почти всё время пользуюсь видеокартами nVidia: GeForce 7600 GT, GeForce 8600M GT, GeForce GT540M. Как правило — чем число больше, тем чип сильнее, за исключением смены модельных обозначений (что в моём примере произошло перед GT540M). Чем же он сильнее? Сейчас и узнаем.

Вспомним немного о “больших играх”. Только ленивый не заметил, что они заставляют быстро устаревать вовсе не процессоры, а видеокарты. Но видеокарты существенно отличаются не из-за объёма памяти — а чего тогда? Напрашивается вывод — они участвуют в непосредственном расчёте картинки и имеют некие вычислительные мощности. На это же указывает факт, что памяти на видеокарте много, явно больше, чем нужно просто для передачи картинки на экран. Кстати, тут неплохо бы привести цифры. Будем считать, что каждый пиксель занимает 4 байта:

\( 1920 \cdot 1080 \cdot 4 = 8294400 \)

Каких-то 8 с лишним мегабайт. Зачем же их сотни?

Началось всё с 3dfx, которые когда-то выпускали видеокарточки, ускоряющие 3D-графику. С тех пор за видеокартами закрепилось это назначение. В 3D картинки традиционно выполняют не в виде отдельных точек (что мало какая память выдержит), а в виде описаний контуров фигур. Грани, рёбра, вершины. И видеокарты принимают на себя обязанность перевести набор граней и рёбер в пиксельную картинку с учётом текстур, цветов и множества других факторов.

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

  1. Обработка переданных вершин — обычно, здесь вычисляется освещение 3D-сцены. Свет (в простейшем случае) виден лишь на поверхностях, поэтому разумно исследовать только то, на чём поверхности базируются. А каждому полигону поверхности необходимо три точки-вершины. Здесь работает вершинный шейдер, который позволяет эту стадию запрограммировать самостоятельно — скажем, переместить вершины по определённому закону в другие места (если это поверхность воды, то добавить волны, например).
  2. Обрезка — незачем рисовать то, что на экран всё равно не влезает. На этой стадии все элементы сцены, не участвующие в отрисовке, исключаются из обрабатываемого материала.
  3. Проекция — всякая весёлая математика, касающаяся вида проекции — перспективная проекция, или прямая (она же ортогональная). Мы сейчас будем иметь дело с прямой, в 3D обычно используется перспектива.
  4. Растеризация — преобразование получившейся сцены в точки (фрагменты), согласно разрешению картинки.
  5. Раскрашивание — момент, когда фрагменты становятся пикселями. Тут есть варианты. Пиксель либо приобретает цвет находившихся рядом поверхностей, либо получает его из текстуры, либо (самое интересное) из фрагментного шейдера. После этого этапа фрагменты становятся полноправными пикселями и отправляются на экран.

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

Целясь в пиксели — уже отметили, что пикселей на FullHD-экране больше двух миллионов, и процессору необходимо выполнить процедуру для каждого. Многовато. Центральный процессор моего ноутбука за секунду секунду делает \( 2.1 \cdot 10^{9} \) тактов. Пусть у нас 60 кадров в секунду — получается 35 миллионов тактов на кадр. То есть, на каждый пиксель приходятся считанные такты, чуть больше десятка. И это не считая того, что есть ещё и механика игры, ОС, и много чего ещё. Мораль — не надо считать такие вещи на ЦП.

Поэтому сейчас чаще говорят не “видеокарта”, а “GPU”. И, в некотором смысле, его противопоставляют CPU — центральному процессору. ЦП обычно состоит из нескольких мощных ядер. GPU же состоит из множества “графических ядер”. Для сравнения: в моём компьютере ЦП состоит из двух ядер (Core i3-2310M), а ГП содержит 96 ядер (GeForce GT540M). Но при таком количестве ядер возникают сложности, с которыми проще бороться с помощью запретов, чем усложнять аппаратуру.

Один из таких запретов — не надо (по возможности) пользоваться циклами и if’ами, потому что графическое ядро плохо умеет перескакивать из одного места программы в другое. В университете я столкнулся с программой, которая считала минимум двух чисел без скачков — сначала записывалось одно число, затем в случае минимальности другого (что сделано без скачков, хитрым математическим трюком), оно записывалось поверх. Если кому-то интересно — вот статья с кучей подобных трюков (она на английском, если что).

Другой нюанс программирования на GPU — разные процессы с одним и тем же шейдером никак не должны зависеть друг от друга. Это связано с тем, что выполняются они параллельно. Скажем, если вы делаете волну на воде — вы можете построить программу так, чтобы из точки на “спокойной поверхности воды” и каких-то неменяющихся (в текущем кадре) данных из игры (момент времени?) получалась точка воды в том же месте (если смотреть сверху), но на высоте, учитывающей бегущую по воде волну. Один из вариантов.

Теперь к практике. В GameMaker добавлен новый вид ресурса — шейдер. На вид — очень напоминает скрипт, но состоит из двух частей — вершинный и фрагментный. Должны быть оба. Если один из них ничего не должен делать, то нужно написать особый “пропускающий” шейдер, который делает то же, что происходит “по умолчанию”. Их я и разберу. Далее я буду работать с языком GLSL ES, максимально совместимым со всеми платформами, куда GameMaker Studio умеет экспортировать. Он сильно отличается от GML, вот документация по нему, я разберу основы его синтаксиса на примерах. Замечу, что если вы уже читали мои уроки про GML и/или знаете, какого стиля в нём и придерживаюсь — то найдёте много общего.

Итак, поехали. Пропускающий вершинный шейдер, для каждой вершины вот что он сделает:

//Наверху "входные данные" шейдера, или аргументы.
//vecN — это N-компонентный вектор. В GLSL есть vec2, vec3 и vec4. Больше никаких.
//Те, что обозначены attribute, подаются из GM автоматически
attribute vec3 in_Position;                  // Координаты вершины
/*x, y, z*/
attribute vec4 in_Colour;                    // Цвет вершины:
/*красный, зелёный, синий, прозрачность*/
attribute vec2 in_TextureCoord;              // Текстурные координаты
/*u и v, это применяется для натягивания текстуры на фигуры*/
//attribute vec3 in_Normal;                  // Это тоже подаётся, но мы это не используем

//Это то, что шейдер вычислит — новые текстурные координаты (2D) и её цвет (RGBA, как выше)
varying vec2 v_vTexcoord;
varying vec4 v_vColour;
//При этом шейдер обязательно должен вычислить значение положения вершины: gl_position

void main()
{
    //В математике принято для преобразований использовать матрицы 4х4.
    //Не просто так, но объяснять это сейчас не нужно.
    //Но нужно добавить к вектору положения четвёртую координату-единицу.
    vec4 object_space_pos = vec4( in_Position.x, in_Position.y, in_Position.z, 1.0);

    //Матрицы в линейной алгебре используются для преобразования фигур:
    //разнообразных поворотов, смещений, растяжений.
    //И применить это преобразование возможно с помощью умножения матрицы на вектор.
    //Матрица 4х4, вектор 4х1 (4 в высоту). Умножать матрицу 4х4 на вектор 3х1 невозможно.
    //Кстати, сомножители нельзя менять местами — мы работаем не с числами!
    //Результат запишется в системную переменную gl_Position (см. выше)
    //Массив gm_Matrices поставляется из GM. Матрица MATRIX_WORLD_VIEW_PROJECTION
    //преобразует координаты так, чтобы точка попала в нужное положение, согласно
    //настройкам вида (координаты, наклон).
    gl_Position = gm_Matrices[MATRIX_WORLD_VIEW_PROJECTION] * object_space_pos;
    
    //Поскольку это ленивый шейдер, он ничего с цветом и координатами не делает.
    //Просто сразу пишет в результат, и всё.
    v_vColour = in_Colour;
    v_vTexcoord = in_TextureCoord;
}

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

//На вход пришли текстурные координаты и цвет вершины
varying vec2 v_vTexcoord;
varying vec4 v_vColour;

void main()
{
    //Мы должны выдать пикселю цвет.
    //Умножение вектора на вектор в GLSL работает "почленно": получается
    //вектор с умноженными (первый.r * второй.r; первый.g * второй.g; ...)
    //texture2D возвращает цвет пикселя, который мы рисуем с текстуры (в которой
    //записан наш спрайт или иная форма) в указанной на ней точке.
    gl_FragColor = v_vColour * texture2D( gm_BaseTexture, v_vTexcoord );
}

Отмечу, кстати, что в OpenGL интенсивность цвета кодируется не от 0 до 255 (только целые), а от 0 до 1 (любые). Это одна из причин, по которой какой image_blend в объект не запиши, светлее оригинала он стать не сможет. Любое неотрицательное число, умноженное на число меньшее 1, не может стать больше.

Немножко об операции .. В GLSL, как в C, есть составные типы. В случае с GM их можно сравнить с объектами, в которых есть только фиксированный набор переменных. В vec4 есть 4 переменных. Но интересно то, что обращаться к ним можно, используя разные имена. В соответствующем порядке: r, g, b, a (типично для цвета); x, y, z, w (типично для положения); s, t, p, q (типично для текстурных координат). Использовать можно любые, порядковый номер названия у которого не больше размера вектора. Скажем, использование w у vec3 закончится ошибкой.

Если мы возьмём этот шейдер, создадим объект со спрайтом, а в рисовании объекта напишем вот это:

if( shader_is_compiled( вон_тот_шейдер ) )
{
    shader_set(вон_тот_шейдер);
    draw_self();
    shader_reset();
}
else
{
    draw_text(10,40,"Шейдер не скомпилировался.");
}

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

//Шейдер фрагментный - получили текстурные координаты и цвет из вершин
varying vec2 v_vTexcoord;
varying vec4 v_vColour;

void main()
{
    //Мы это уже видели, да?
    gl_FragColor = (v_vColour * texture2D( gm_BaseTexture, v_vTexcoord ));
    //Добавим весёлого синтаксиса GLSL! b и g — два отдельных поля из вектора
    //цвета. Поля там называются r, g, b, a, именно в таком порядке. А вектор
    //bg — это вектор из двух чисел, представляющих компоненты b и g
    //в соответствующем порядке. Синяя и зелёная компоненты.
    gl_FragColor.bg = vec2(0.0, 0.0);
    //vec2 — сборка вектора из двух компонент с указанными значениями.
}

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