Перетяжка, Премия ТПрогер, 13.11
Перетяжка, Премия ТПрогер, 13.11
Перетяжка, Премия ТПрогер, 13.11

Space Invaders «с нуля» — Часть 2: настраиваем шейдеры OpenGL и рисуем спрайт пришельца

Как написать собственный клон Space Invaders на C++ с помощью OpenGL: создание буфера, работа с шейдерами, текстурами и спрайтами. Подробное руководство для начинающих гейм-девелоперов и энтузиастов графики.

103 открытий1К показов
Space Invaders «с нуля» — Часть 2: настраиваем шейдеры OpenGL и рисуем спрайт пришельца

Это перевод статьи автора Nick Tasios. Перевод первой части мы уже публиковали: там настроили окно и контекст. Во второй части настроим шейдеры OpenGL, чтобы отрисовать спрайт пришельца! Спрайт (sprite) — это двумерное графическое изображение, которое используется для отображения объектов в играх и интерфейсах. Если проще — картинка персонажа, предмета или эффекта, которая выводится на экран поверх фона. Код для действий этой части можно посмотреть в репозитории на GitHub.

Рендеринг на стороне CPU

GPU отлично справляется с обработкой больших объёмов данных, но программировать графический процессор сложнее, чем CPU. Для простого клона Space Invaders, который я создаю здесь, проще выполнять всю отрисовку на центральном процессоре (CPU), используя буфер — участок памяти, представляющий собой пиксели игрового экрана. Затем этот буфер можно передать на GPU в виде текстуры и вывести на экран компьютера.

			struct Buffer
{
    size_t width, height;
    uint32_t* data;
};

		

Буфер имеет определённую ширину и высоту. Мы представляем каждый пиксель в виде значения типа uint32_t, что позволяет хранить четыре 8-битных компонента цвета для каждого пикселя. В нашем случае мы будем использовать только 24 бита — по 8 бит для красного (red), зелёного (green) и синего (blue) каналов. Можно возразить, что логичнее было бы использовать uint8_t, но я применяю 32-битное значение, потому что так проще работать с индексами при обращении к элементам массива. Чтобы использовать эти типы целых чисел с фиксированной разрядностью, необходимо подключить стандартный заголовочный файл:

			#include <cstdint>
		

Чтобы упростить определение цветов в виде значений типа uint32_t, мы создадим следующую функцию:

			uint32_t rgb_to_uint32(uint8_t r, uint8_t g, uint8_t b)
{
    return (r << 24) | (g << 16) | (b << 8) | 255;
}

		

Эта функция устанавливает три старших байта (24 бита) в значения r, g и b соответственно.

Оставшиеся 8 младших битов заполняются значением 255 — хотя, как упоминалось ранее, альфа-канал (прозрачность) в нашем случае не используется. Далее мы создаём функцию, которая очищает буфер, заполняя его указанным цветом.

			void buffer_clear(Buffer* buffer, uint32_t color)
{
    for(size_t i = 0; i < buffer->width * buffer->height; ++i)
    {
        buffer->data[i] = color;
    }
}

		

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

Теперь, в главной функции (main), мы инициализируем буфер — то есть создаём область памяти, где будут храниться все пиксели экрана:

			uint32_t clear_color = rgb_to_uint32(0, 128, 0);
Buffer buffer;
buffer.width  = buffer_width;
buffer.height = buffer_height;
buffer.data   = new uint32_t[buffer.width * buffer.height];
buffer_clear(&buffer, clear_color);

		

Этот код создаст буфер с шириной buffer_width и высотой buffer_height, а также заполнит его цветом clear_color, то есть зелёным.

Шейдеры OpenGL

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

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

Старый OpenGL иногда называют fixed pipeline — «фиксированный конвейер», поскольку его этапы были заранее определены и не могли быть изменены программистом.

В современном OpenGL почти все этапы программируемы, и основные среди них два:

  • Vertex Shader (вершинный шейдер) — обрабатывает данные вершин, обычно выполняет трансформацию объектов в координаты экрана.
  • Fragment Shader (фрагментный шейдер) — работает с пикселями, полученными после растеризации. Он определяет их цвет, глубину и, при необходимости, трафаретные значения (stencil).

Эти два шейдера — минимально необходимые для работы любого рендеринга в OpenGL.

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

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

			const char* vertex_shader =
    "\n"
    "#version 330\n"
    "\n"
    "noperspective out vec2 TexCoord;\n"
    "\n"
    "void main(void){\n"
    "\n"
    "    TexCoord.x = (gl_VertexID == 2)? 2.0: 0.0;\n"
    "    TexCoord.y = (gl_VertexID == 1)? 2.0: 0.0;\n"
    "    \n"
    "    gl_Position = vec4(2.0 * TexCoord - 1.0, 0.0, 1.0);\n"
    "}\n";

		

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

			const char* fragment_shader =
    "\n"
    "#version 330\n"
    "\n"
    "uniform sampler2D buffer;\n"
    "noperspective in vec2 TexCoord;\n"
    "\n"
    "out vec3 outColor;\n"
    "\n"
    "void main(void){\n"
    "    outColor = texture(buffer, TexCoord).rgb;\n"
    "}\n";

		

Обратите внимание, что выходное значение вершинного шейдера TexCoord теперь используется как вход для фрагментного шейдера. Хотя вершинному шейдеру не требуется передавать какие-либо данные о вершинах, мы всё же должны указать, что будем рисовать три вершины. Для этого создаётся объект вершинного массива (VAO).

			GLuint fullscreen_triangle_vao;
glGenVertexArrays(1, &fullscreen_triangle_vao);
glBindVertexArray(fullscreen_triangle_vao);

		

Если упростить, VAO (Vertex Array Object) — это структура в OpenGL, которая хранит формат вершинных данных вместе с самими данными вершин. Наконец, оба шейдера нужно скомпилировать в код, понятный GPU, а затем связать их в единый шейдерный программный объект.

			GLuint shader_id = glCreateProgram();

//Create vertex shader
{
    GLuint shader_vp = glCreateShader(GL_VERTEX_SHADER);

    glShaderSource(shader_vp, 1, &vertex_shader, 0);
    glCompileShader(shader_vp);
    validate_shader(shader_vp, vertex_shader);
    glAttachShader(shader_id, shader_vp);

    glDeleteShader(shader_vp);
}

//Create fragment shader
{
    GLuint shader_fp = glCreateShader(GL_FRAGMENT_SHADER);

    glShaderSource(shader_fp, 1, &fragment_shader, 0);
    glCompileShader(shader_fp);
    validate_shader(shader_fp, fragment_shader);
    glAttachShader(shader_id, shader_fp);

    glDeleteShader(shader_fp);
}

glLinkProgram(shader_id);

if(!validate_program(shader_id))
{
    fprintf(stderr, "Error while validating shader.\n");
    glfwTerminate();
    glDeleteVertexArrays(1, &fullscreen_triangle_vao);
    delete[] buffer.data;
    return -1;
}

		

В приведённом выше фрагменте кода шейдерная программа сначала создаётся с помощью функции glCreateProgram. Отдельные шейдеры создаются функцией glCreateShader и компилируются с помощью glCompileShader. После этого они присоединяются к программе через glAttachShader, после чего сами объекты шейдеров можно удалить.

Программа связывается вызовом glLinkProgram. Во время компиляции OpenGL выводит различную информацию — аналогично тому, как это делает компилятор C++. Однако, чтобы получить эти сообщения, их нужно перехватывать вручную. Для этого созданы две простые функции: validate_shader и validate_program.

			void validate_shader(GLuint shader, const char* file = 0)
{
    static const unsigned int BUFFER_SIZE = 512;
    char buffer[BUFFER_SIZE];
    GLsizei length = 0;

    glGetShaderInfoLog(shader, BUFFER_SIZE, &length, buffer);

    if(length > 0)
    {
        printf("Shader %d(%s) compile error: %s\n",
            shader, (file ? file: ""), buffer);
    }
}

bool validate_program(GLuint program)
{
    static const GLsizei BUFFER_SIZE = 512;
    GLchar buffer[BUFFER_SIZE];
    GLsizei length = 0;

    glGetProgramInfoLog(program, BUFFER_SIZE, &length, buffer);

    if(length > 0)
    {
        printf("Program %d link error: %s\n", program, buffer);
        return false;
    }

    return true;
}

		

Текстура буфера

Для передачи данных изображения на GPU используется текстура OpenGL. Как и в случае с VAO, текстура также объект, который вместе с данными изображения содержит информацию о формате этих данных. Сначала текстура создаётся с помощью функции glGenTextures.

			GLuint buffer_texture;
glGenTextures(1, &buffer_texture);

		

Затем задаём формат изображения, а также некоторые стандартные параметры, определяющие поведение выборки текстуры.

			glBindTexture(GL_TEXTURE_2D, buffer_texture);
glTexImage2D(
    GL_TEXTURE_2D, 0, GL_RGB8,
    buffer.width, buffer.height, 0,
    GL_RGBA, GL_UNSIGNED_INT_8_8_8_8, buffer.data
);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

		

Здесь мы указываем, что изображение должно использовать 8-битный RGB-формат для внутреннего представления текстуры. Последние три параметра вызова glTexImage2D определяют формат пикселей данных, которые мы передаём в текстуру: каждый пиксель находится в формате RGBA и представлен четырьмя беззнаковыми 8-битными целыми числами.

Два первых вызова glTexParameteri сообщают GPU не применять фильтрацию (сглаживание) при чтении пикселей. Два последних указывают, что при попытке чтения за пределами текстуры будет использоваться значение на границе.

Теперь нам нужно привязать текстуру к uniform-переменной sampler2D во фрагментном шейдере. В OpenGL существует несколько текстурных единиц, к которым может быть привязан uniform. Мы получаем расположение uniform-переменной в шейдере (его можно рассматривать как своего рода «указатель») с помощью glGetUniformLocation и устанавливаем её на текстурную единицу 0 с помощью вызова glUniform1i.

			GLint location = glGetUniformLocation(shader_id, "buffer");
glUniform1i(location, 0);

		

Отображение буфера

Наконец, мы настроили всё необходимое, чтобы вывести буфер на экран. Непосредственно перед запуском игрового цикла мы отключаем тест глубины и привязываем созданный ранее объект вершинного массива (VAO).

			glDisable(GL_DEPTH_TEST);
glBindVertexArray(fullscreen_triangle_vao);

		

Если теперь вызвать:

			glDrawArrays(GL_TRIANGLES, 0, 3);

		

То содержимое созданного окна должно отобразиться зелёным цветом.

Space Invaders «с нуля» — Часть 2: настраиваем шейдеры OpenGL и рисуем спрайт пришельца 1

Отрисовка спрайта

В предыдущем посте мы уже создали окно и вывели на экран цвет. Заканчивать эту часть на том же этапе было бы скучно — так что теперь давайте что-нибудь нарисуем.

Сначала я определяю простой спрайт:

			struct Sprite
{
    size_t width, height;
    uint8_t* data;
};

		

Это просто участок данных, размещённых в куче, вместе с указанием ширины и высоты спрайта. Сам спрайт представлен в виде битовой карты (bitmap) — то есть каждый пиксель кодируется одним битом, где значение 1 означает, что пиксель спрайта включён (“on”).

Далее мы создаём функцию, которая отрисовывает спрайт в буфере с заданным цветом.

			void buffer_sprite_draw(
    Buffer* buffer, const Sprite& sprite,
    size_t x, size_t y, uint32_t color
){
    for(size_t xi = 0; xi < sprite.width; ++xi)
    {
        for(size_t yi = 0; yi < sprite.height; ++yi)
        {
            size_t sy = sprite.height - 1 + y - yi;
            size_t sx = x + xi;
            if(sprite.data[yi * sprite.width + xi] &&
               sy < buffer->height && sx < buffer->width) 
            {
                buffer->data[sy * buffer->width + sx] = color;
            }
        }
    }
}

		

Функция просто проходит по пикселям спрайта и рисует активные (“on”) пиксели по указанным координатам, если они находятся в пределах границ буфера.

Альтернативно можно представить спрайт так же, как и буфер — то есть каждый пиксель хранится как 32-битное RGBA-значение, где альфа-канал используется для прозрачности или смешивания.

В главной функции (main) мы создаём спрайт пришельца (alien sprite).

			Sprite alien_sprite;
alien_sprite.width = 11;
alien_sprite.height = 8;
alien_sprite.data = new uint8_t[11 * 8]
{
    0,0,1,0,0,0,0,0,1,0,0, // ..@.....@..
    0,0,0,1,0,0,0,1,0,0,0, // ...@...@...
    0,0,1,1,1,1,1,1,1,0,0, // ..@@@@@@@..
    0,1,1,0,1,1,1,0,1,1,0, // .@@.@@@.@@.
    1,1,1,1,1,1,1,1,1,1,1, // @@@@@@@@@@@
    1,0,1,1,1,1,1,1,1,0,1, // @.@@@@@@@.@
    1,0,1,0,0,0,0,0,1,0,1, // @.@.....@.@
    0,0,0,1,1,0,1,1,0,0,0  // ...@@.@@...
};

		

Чтобы нарисовать красный спрайт в позиции (112, 128), вызываем:

			buffer_sprite_draw(&buffer, alien_sprite,
    112, 128, rgb_to_uint32(128, 0, 0));
		

Чтобы подготовиться к следующим шагам, мы очищаем буфер и рисуем спрайт на каждом кадре игрового цикла. Для обновления текстуры OpenGL вызываем функцию glTexSubImage2D.

			glTexSubImage2D(
    GL_TEXTURE_2D, 0, 0, 0,
    buffer.width, buffer.height,
    GL_RGBA, GL_UNSIGNED_INT_8_8_8_8,
    buffer.data
);

		

Если скомпилировать и запустить финальный код из этого поста, то вы увидите красную текстуру в центре зелёного фона.

Space Invaders «с нуля» — Часть 2: настраиваем шейдеры OpenGL и рисуем спрайт пришельца 2

В этом посте мы подготовили всё необходимое, чтобы отрисовывать спрайты на CPU и выводить их в окно, созданное ранее. В современном OpenGL это требует определённого объёма работы — мы затронули такие темы, как объекты вершинных буферов, шейдеры и текстуры.

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

Следите за новыми постами
Следите за новыми постами по любимым темам
103 открытий1К показов