Чтобы привнести немного жизни в черную бездну нашего игрового мира, мы добавим в нее несколько спрайтов. У термина спрайт существует много различных определений, но фактически — это 2D-изображение, используемое вместе с некоторой информацией (например, информацией о его положении, повороте и размере), задающей его позиционирование в окружающем пространстве. Если говорить совсем по-простому, то спрайты — это визуализируемые объекты изображений/текстур, которые мы будем использовать в нашей 2D-игре.
Как и на предыдущих уроках, мы можем создавать 2D-фигуру из набора вершинных данных, затем передавать их в графический процессор и после этого вручную производить все необходимые преобразования. Однако в более крупном приложении, как наше, предпочтительнее иметь некоторые абстракции для рендеринга 2D-фигур. Иначе, если бы мы продолжили для каждого объекта самостоятельно задавать его форму и преобразования, то это быстро бы привело к беспорядку в исходном коде программы.
На этом уроке мы определим класс рендеринга, который позволит нам при минимальном объеме кода визуализировать большое количество уникальных спрайтов. Таким образом, мы абстрагируем код геймплея от остального кода рендеринга, как это обычно делается в более крупных проектах. А начнем с того, что подготовим необходимую матрицу проекции.
Матрица 2D-проекции
Из урока о системе координат в OpenGL нам известно, что проекционная матрица преобразует все координаты пространства вида в координаты отсеченного пространства (а затем в нормализованные координаты устройства). Имея соответствующую матрицу проекции, в отличие от варианта прямого задания всех координат в виде нормализованных координат устройства, мы можем работать с той системой координат, с которой нам это будет проще.
Так как игра создается полностью в 2D-пространстве (а это значит, что про третье измерение (перспективу) можно забыть), для рендеринга мы задействуем матрицу ортографической проекции. Матрица ортографической проекции напрямую преобразует координаты вершин в нормализованные координаты устройства. У нас появляется возможность применять координаты мирового пространства в качестве координат экрана, если определять проекционную матрицу следующим образом:
1 |
glm::mat4 projection = glm::ortho(0.0f, 800.0f, 600.0f, 0.0f, -1.0f, 1.0f); |
Первые четыре аргумента определяют (по порядку) левую, правую, нижнюю и верхнюю части усеченной пирамиды проекции. Описанная проекционная матрица преобразует все x-координаты из диапазона [0; 800]
в диапазон [-1; 1]
, а все y-координаты — из диапазона [0; 600]
в диапазон [-1; 1]
. Вышеописанной строкой кода мы указываем, что верхняя часть усеченной пирамиды имеет y-координату, равную 0
, а нижняя часть — y-координату, равную 600
. В результате верхний левый угол сцены будет иметь координаты (0; 0)
, а нижний правый — координаты (800; 600)
, точно так же, как и координаты экрана; координаты мирового пространства напрямую соответствуют координатам результирующих пикселей.
Благодаря этому у нас появляется возможность задать прямое (и что самое главное — интуитивно понятное) соответствие между координатами вершин и пикселями на экране, в которые попадут вершины.
Рендеринг спрайтов
Процесс рендеринга спрайтов не слишком сложен. Для начала необходимо создать текстурированный прямоугольник, который можно трансформировать при помощи матрицы модели, после чего проецируем его, используя ранее определенную матрицу ортографической проекции.
Примечание: Поскольку «Breakout» — это игра с одной сценой, то нам не потребуются матрицы вида/камеры. При помощи матрицы проекции можно напрямую преобразовать координаты мирового пространства в нормализованные координаты устройства.
Для преобразования спрайта мы будем использовать следующий вершинный шейдер:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#version 330 core layout (location = 0) in vec4 vertex; out vec2 TexCoords; uniform mat4 model; uniform mat4 projection; void main() { TexCoords = vertex.zw; gl_Position = projection * model * vec4(vertex.xy, 0.0, 1.0); } |
Обратите внимание, что данные как координат позиции, так и текстурных координат, сохранены в одной переменной типа vec4. Поскольку координаты позиции и текстурные координаты содержат два значения типа float, то можно объединить их в один атрибут вершины.
Фрагментный шейдер также относительно прост. Мы берем текстуру и цветовой вектор, которые определяют конечный цвет фрагмента. А имея uniform-переменную цветового вектора, мы можем легко управлять цветом спрайтов непосредственно из игрового кода:
1 2 3 4 5 6 7 8 9 10 11 |
#version 330 core in vec2 TexCoords; out vec4 color; uniform sampler2D image; uniform vec3 spriteColor; void main() { color = vec4(spriteColor, 1.0) * texture(image, TexCoords); } |
Чтобы сделать рендеринг спрайтов более организованным, напишем класс SpriteRenderer, который способен визуализировать спрайт при помощи всего лишь одной функции. Его определение выглядит следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class SpriteRenderer { public: SpriteRenderer(const Shader &shader); ~SpriteRenderer(); void DrawSprite(const Texture2D &texture, glm::vec2 position, glm::vec2 size = glm::vec2(10.0f, 10.0f), float rotate = 0.0f, glm::vec3 color = glm::vec3(1.0f)); private: Shader shader; unsigned int quadVAO; void initRenderData(); }; |
Класс SpriteRenderer содержит шейдер и один VAO, а также функцию визуализации и инициализации. Конструктор класса в качестве параметра принимает шейдер, который будет использоваться для всего последующего рендеринга.
Инициализация
Сначала давайте подробнее рассмотрим функцию initRenderData(), которая настраивает переменную quadVAO
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
void SpriteRenderer::initRenderData() { // Конфигурирование VAO/VBO unsigned int VBO; float vertices[] = { 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f }; glGenVertexArrays(1, &this->quadVAO); glGenBuffers(1, &VBO); glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); glBindVertexArray(this->quadVAO); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindVertexArray(0); } |
В данном фрагменте кода мы сначала определяем набор вершин. При этом стоит заметить, что точка (0; 0)
является верхним левым углом прямоугольника. Это означает, что когда мы применим к прямоугольнику перенос или масштабирование, то они будут выполняться относительного этого угла. Обычно данный подход можно встретить в 2D-графических системах, где координаты позиции элемента привязаны к верхнему левому углу элемента.
Затем мы просто отправляем вершины в GPU и настраиваем атрибуты вершин; в данном случае в наличии единственный атрибут вершины. Поскольку все спрайты используют одни и те же данные вершин, то для их рендеринга нужно определить только один VAO.
Рендеринг
Теперь давайте разберем вопрос рендеринга спрайтов; мы задействуем шейдер рендеринга спрайтов, настраиваем матрицу модели и устанавливаем соответствующую uniform-переменную. Здесь важную роль играет порядок преобразований:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
void SpriteRenderer::DrawSprite(const Texture2D &texture, glm::vec2 position, glm::vec2 size, float rotate, glm::vec3 color) { // Подготовка преобразований this->shader.Use(); glm::mat4 model = glm::mat4(1.0f); model = glm::translate(model, glm::vec3(position, 0.0f)); model = glm::translate(model, glm::vec3(0.5f * size.x, 0.5f * size.y, 0.0f)); model = glm::rotate(model, glm::radians(rotate), glm::vec3(0.0f, 0.0f, 1.0f)); model = glm::translate(model, glm::vec3(-0.5f * size.x, -0.5f * size.y, 0.0f)); model = glm::scale(model, glm::vec3(size, 1.0f)); this->shader.SetMatrix4("model", model); this->shader.SetVector3f("spriteColor", color); glActiveTexture(GL_TEXTURE0); texture.Bind(); glBindVertexArray(this->quadVAO); glDrawArrays(GL_TRIANGLES, 0, 6); glBindVertexArray(0); } |
Когда вы будете размещать внутри сцены свои объекты, то рекомендуется первой применять операцию масштабирования, затем поворота и, наконец, переноса объекта. Поскольку умножение матриц происходит справа налево, то мы преобразуем матрицу в обратном порядке: переносим, поворачиваем, а затем масштабируем.
Преобразование поворота все еще может показаться вам немного пугающим. Из урока о трансформациях в OpenGL известно, что поворот всегда выполняется вокруг точки с координатами (0; 0)
. Поскольку в нашем случае координаты (0; 0)
соответствуют верхнему левому углу прямоугольника, то все повороты будут происходить относительно этого угла. Такое расположение точки начала поворота может привести к нежелательным результатам. Поэтому необходимо переместить точку начала поворота в центр прямоугольника, чтобы он поворачивался вокруг центральной точки, а не вокруг своего верхнего левого угла. Мы решаем эту проблему, перемещая прямоугольник на половину своего размера таким образом, чтобы перед операцией поворота его центр имел координаты (0; 0)
.
Поскольку первым действием мы масштабируем прямоугольник, то должны учитывать размер спрайта при перемещении точки начала поворота в центр спрайта, поэтому мы производим умножение на вектор size
спрайта. Как только преобразование поворота будет применено, мы вернем начальный размер спрайту.
Комбинируя все эти преобразования, мы можем позиционировать, масштабировать и поворачивать каждый спрайт так, как нам нравится.
#Класс SpriteRenderer
Заголовочный файл — sprite_renderer.h:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
#ifndef SPRITE_RENDERER_H #define SPRITE_RENDERER_H #include <glad/glad.h> #include <glm/glm.hpp> #include <glm/gtc/matrix_transform.hpp> #include "texture.h" #include "shader.h" class SpriteRenderer { public: // Конструктор (инициализируем шейдеры/объекты) SpriteRenderer(const Shader &shader); // Деструктор ~SpriteRenderer(); // Рендерим текстурированный прямоугольник по заданному спрайту void DrawSprite(const Texture2D &texture, glm::vec2 position, glm::vec2 size = glm::vec2(10.0f, 10.0f), float rotate = 0.0f, glm::vec3 color = glm::vec3(1.0f)); private: // Состояние рендера Shader shader; unsigned int quadVAO; // Инициализируем и настраиваем атрибуты буфера и атрибуты вершин void initRenderData(); }; #endif |
Файл реализации — sprite_renderer.cpp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
#include "sprite_renderer.h" SpriteRenderer::SpriteRenderer(const Shader &shader) { this->shader = shader; this->initRenderData(); } SpriteRenderer::~SpriteRenderer() { glDeleteVertexArrays(1, &this->quadVAO); } void SpriteRenderer::DrawSprite(const Texture2D &texture, glm::vec2 position, glm::vec2 size, float rotate, glm::vec3 color) { // Подготовка преобразований this->shader.Use(); glm::mat4 model = glm::mat4(1.0f); model = glm::translate(model, glm::vec3(position, 0.0f)); // сначала выполняем перемещение (преобразования таковы: первым происходит масштабирование, затем поворот, а в конце - перенос; действия с матрицами в обратном порядке) model = glm::translate(model, glm::vec3(0.5f * size.x, 0.5f * size.y, 0.0f)); // перемещаем точку начала поворота в центр прямоугольника model = glm::rotate(model, glm::radians(rotate), glm::vec3(0.0f, 0.0f, 1.0f)); // затем производим поворот model = glm::translate(model, glm::vec3(-0.5f * size.x, -0.5f * size.y, 0.0f)); // возвращаем точку поворота в исходную позицию model = glm::scale(model, glm::vec3(size, 1.0f)); // последним выполняется масштабирование this->shader.SetMatrix4("model", model); // Рендерим текстурированный прямоугольник this->shader.SetVector3f("spriteColor", color); glActiveTexture(GL_TEXTURE0); texture.Bind(); glBindVertexArray(this->quadVAO); glDrawArrays(GL_TRIANGLES, 0, 6); glBindVertexArray(0); } void SpriteRenderer::initRenderData() { // Конфигурируем VAO/VBO unsigned int VBO; float vertices[] = { 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f }; glGenVertexArrays(1, &this->quadVAO); glGenBuffers(1, &VBO); glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); glBindVertexArray(this->quadVAO); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindVertexArray(0); } |
Hello, спрайт
С созданием класса SpriteRenderer мы наконец-то получили возможность рендерить реальные изображения на экране! Давайте попробуем инициализировать какое-нибудь изображение, загрузив нашу любимую текстуру:
Код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
SpriteRenderer *Renderer; void Game::Init() { // Загрузка шейдеров ResourceManager::LoadShader("../shaders/sprite.vs", "../shaders/sprite.frag", nullptr, "sprite"); // Конфигурирование шейдеров glm::mat4 projection = glm::ortho(0.0f, static_cast<float>(this->Width), static_cast<float>(this->Height), 0.0f, -1.0f, 1.0f); ResourceManager::GetShader("sprite").Use().SetInteger("image", 0); ResourceManager::GetShader("sprite").SetMatrix4("projection", projection); // Установка специфичных для рендеринга элементов управления Renderer = new SpriteRenderer(ResourceManager::GetShader("sprite")); // Загрузка текстур ResourceManager::LoadTexture("../textures/awesomeface.png", true, "face"); } |
Затем с помощью функции рендеринга мы можем визуализировать наш любимый талисман, чтобы увидеть, всё ли работает так, как должно:
1 2 3 4 5 |
void Game::Render() { Renderer->DrawSprite(ResourceManager::GetTexture("face"), glm::vec2(200.0f, 200.0f), glm::vec2(300.0f, 400.0f), 45.0f, glm::vec3(0.0f, 1.0f, 0.0f)); } |
Здесь мы помещаем спрайт несколько ближе к центру экрана, причем его высота немного больше ширины. Мы также поворачиваем спрайт на 45 градусов и придаем ему зеленый цвет.
Если вы всё сделали правильно, то должны получить следующий результат:
GitHub / Часть №3: Рендеринг спрайтов в игре «Breakout» на С++/OpenGL — Исходный код
Теперь, когда мы реализовали работающую систему рендеринга, можно использовать её на следующем уроке, на котором мы поработаем над созданием игровых уровней.