Представьте, как было бы круто, если бы мы оживили нашу игру, добавив в нее несколько эффектов постобработки? Например, создали бы эффект «Встряски», инвертировали все цвета сцены, сделали хаотичное перемещение вершин и/или использовали другие необычные эффекты.
Примечание: На этом уроке широко используются теоретические материалы из уроков про фреймбуферы и сглаживание.
На уроке о фреймбуферах мы продемонстрировали, как можно применять постобработку для достижения интересных эффектов при помощи всего одной текстуры. Мы собираемся сделать нечто подобное и в игре «Breakout»: создать фреймбуфер с прикрепленным к нему мультисэмплированным рендербуфером. Весь код рендеринга игры должен рендерить сцену в данный мультисэмплированный фреймбуфер, который, затем, копирует свое содержимое в другой фреймбуфер с прикрепленной к нему текстурой. Данная текстура — это сглаженное изображение игры, которое мы будем рендерить на экранный 2D-прямоугольник с применением (или без) эффектов постобработки.
Итак, подводя итог всего вышесказанного, для осуществления процесса рендеринга с использованием постобработки, мы должны проделать следующие шаги:
Шаг №1: Привязать мультисэмплированный фреймбуфер.
Шаг №2: Выполнить обычный рендеринг игры.
Шаг №3: Скопировать мультисэмплированный фреймбуфер в обычный фреймбуфер с прикрепленной текстурой.
Шаг №4: Отвязать фреймбуфер (использовать заданный по умолчанию фреймбуфер).
Шаг №5: В шейдере постобработки использовать текстуру цветового буфера из обычного фреймбуфера.
Шаг №6: Выполнить рендеринг выходного изображения шейдера постобработки.
Шейдер постобработки позволяет активировать три типа эффектов: Shake, Confuse и Chaos.
Shake (Встряска) — слегка встряхивает и кратковременно немного размывает сцену.
Confuse (Дезориентация) — инвертирует цвета сцены, а также оси x
и y
.
Chaos (Хаос) — использует ядро обнаружения граней для создания интересных визуальных эффектов, а также перемещает текстурированное изображение по кругу для получения забавного эффекта «Хаоса».
Ниже приведен краткий обзор того, как будут выглядеть данные эффекты:
Вершинный шейдер:
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 |
#version 330 core layout (location = 0) in vec4 vertex; out vec2 TexCoords; uniform bool chaos; uniform bool confuse; uniform bool shake; uniform float time; void main() { gl_Position = vec4(vertex.xy, 0.0f, 1.0f); vec2 texture = vertex.zw; if (chaos) { float strength = 0.3; vec2 pos = vec2(texture.x + sin(time) * strength, texture.y + cos(time) * strength); TexCoords = pos; } else if (confuse) { TexCoords = vec2(1.0 - texture.x, 1.0 - texture.y); } else { TexCoords = texture; } if (shake) { float strength = 0.01; gl_Position.x += cos(time * 10) * strength; gl_Position.y += cos(time * 15) * strength; } } |
Активируемый тип эффекта выбирается на основе того, значение какой uniform-переменной установлено в true
. Если значение true
задано для параметра chaos
или confuse
, то вершинный шейдер будет манипулировать текстурными координатами, заставляя сцену вращаться (либо перемещать текстурные координаты по кругу, либо инвертировать их). Поскольку мы установили режим наложения текстур в GL_REPEAT
, то эффект «Хаоса» заставит сцену повторяться в различных частях экранного прямоугольника. Если же переменная shake
установлена в true
, то позиции вершин будут перемещаться на некоторую небольшую величину, создавая эффект дрожания экрана. Обратите внимание, что chaos
и confuse
не должны принимать значения true
одновременно, в то время как эффект «Встряски» (shake) способен работать с любыми другими эффектами.
В дополнение к смещению позиций вершин или текстурных координат, мы также хотели бы создать некоторые визуальные изменения при активности какого-либо из эффектов. Мы можем сделать это во фрагментном шейдере:
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 |
#version 330 core in vec2 TexCoords; out vec4 color; uniform sampler2D scene; uniform vec2 offsets[9]; uniform int edge_kernel[9]; uniform float blur_kernel[9]; uniform bool chaos; uniform bool confuse; uniform bool shake; void main() { color = vec4(0.0f); vec3 sample[9]; // Выборка из текстуры смещений при использовании матрицы свертки if(chaos || shake) for(int i = 0; i < 9; i++) sample[i] = vec3(texture(scene, TexCoords.st + offsets[i])); // Просчет эффектов if (chaos) { for(int i = 0; i < 9; i++) color += vec4(sample[i] * edge_kernel[i], 0.0f); color.a = 1.0f; } else if (confuse) { color = vec4(1.0 - texture(scene, TexCoords).rgb, 1.0); } else if (shake) { for(int i = 0; i < 9; i++) color += vec4(sample[i] * blur_kernel[i], 0.0f); color.a = 1.0f; } else { color = texture(scene, TexCoords); } } |
Код представленного шейдера хоть и выглядит несколько длинным, но он практически полностью опирается на фрагментный шейдер из урока о фреймбуферах, вычисляя несколько эффектов постобработки в зависимости от типа активированного эффекта. Однако на этот раз матрицы смещения и ядра свертки определяются как единое целое, управляемые через OpenGL-код. Преимущество этого подхода в том, что мы должны установить матрицу только один раз, а не пересчитывать её при каждом запуске фрагментного шейдера. Например, матрица offsets
сконфигурирована следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
float offset = 1.0f / 300.0f; float offsets[9][2] = { { -offset, offset }, // вверх-влево { 0.0f, offset }, // вверх-центр { offset, offset }, // вверх-вправо { -offset, 0.0f }, // центр-влево { 0.0f, 0.0f }, // центр-центр { offset, 0.0f }, // центр-вправо { -offset, -offset }, // вниз-влево { 0.0f, -offset }, // вниз-центр { offset, -offset } // вниз-вправо }; glUniform2fv(glGetUniformLocation(shader.ID, "offsets"), 9, (float*)offsets); |
Поскольку все концепции управления (мультисэмплированным) фреймбуфером уже подробно обсуждались на предыдущих уроках, то я не буду углубляться в эти детали. Ниже вы найдете код класса PostProcessor, который управляет инициализацией, записью/чтением фреймбуферов и рендерингом экранного прямоугольника.
#Класс PostProcessor
Заголовочный файл — post_processor.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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
#ifndef POST_PROCESSOR_H #define POST_PROCESSOR_H #include <glad/glad.h> #include <glm/glm.hpp> #include "texture.h" #include "sprite_renderer.h" #include "shader.h" // Класс PostProcessor содержит все используемые в игре "Breakout" эффекты постобработки. // Он производит рендеринг игры в текстурированный прямоугольник, // после чего мы можем задействовать определенные эффекты типа Shake, Confuse или Chaos. // Для работы класса перед рендерингом игры требуется вызвать функцию BeginRender(), // а в конце рендеринга - функцию EndRender() class PostProcessor { public: // Состояние Shader PostProcessingShader; Texture2D Texture; unsigned int Width, Height; // Опции bool Confuse, Chaos, Shake; // Конструктор PostProcessor(Shader shader, unsigned int width, unsigned int height); // Подготовка операций фреймбуфера постпроцессора перед рендерингом игры void BeginRender(); // Фукнция должна вызываться после рендеринга игры, все визуализированные данные сохраняются в текстуре void EndRender(); // Рендерим текстуру объекта класса PostProcessor (в качестве большого, охватывающего экран, спрайта) void Render(float time); private: // Состояния рендеринга unsigned int MSFBO, FBO; // MSFBO = Мультисэмплированный фреймбуфер (FBO) unsigned int RBO; // RBO используется для мультисэмплированного цветового буфера unsigned int VAO; // Инициализация прямоугольника для рендеринга текстуры постобработки void initRenderData(); }; #endif |
Файл реализации — post_processor.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 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
#include "post_processor.h" #include <iostream> PostProcessor::PostProcessor(Shader shader, unsigned int width, unsigned int height) : PostProcessingShader(shader), Texture(), Width(width), Height(height), Confuse(false), Chaos(false), Shake(false) { // Инициализация рендербуфера/фреймбуфера glGenFramebuffers(1, &this->MSFBO); glGenFramebuffers(1, &this->FBO); glGenRenderbuffers(1, &this->RBO); // Инициализация памяти рендербуфера мультисэмплированным цветовым буфером (без использования буфера глубины/трафарета) glBindFramebuffer(GL_FRAMEBUFFER, this->MSFBO); glBindRenderbuffer(GL_RENDERBUFFER, this->RBO); glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_RGB, width, height); // выделение памяти для рендербуфера glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, this->RBO); // прикрепление мультисэмплированного объекта рендербуфера к фреймбуферу if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) std::cout << "ERROR::POSTPROCESSOR: Failed to initialize MSFBO" << std::endl; // Также инициализируем FBO/текстуру для копирования мультисэмплированного цветового буфера; используется в шейдерных вычислениях (эффекты постобработки) glBindFramebuffer(GL_FRAMEBUFFER, this->FBO); this->Texture.Generate(width, height, NULL); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, this->Texture.ID, 0); // прикрепляем текстуру к фреймбуферу if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) std::cout << "ERROR::POSTPROCESSOR: Failed to initialize FBO" << std::endl; glBindFramebuffer(GL_FRAMEBUFFER, 0); // Инициализируем данные и uniform-переменные для рендеринга this->initRenderData(); this->PostProcessingShader.SetInteger("scene", 0, true); float offset = 1.0f / 300.0f; float offsets[9][2] = { { -offset, offset }, // вверх-влево { 0.0f, offset }, // вверх-центр { offset, offset }, // вверх-вправо { -offset, 0.0f }, // центр-влево { 0.0f, 0.0f }, // центр-центр { offset, 0.0f }, // центр-вправо { -offset, -offset }, // вниз-влево { 0.0f, -offset }, // вниз-центр { offset, -offset } // вниз-вправо }; glUniform2fv(glGetUniformLocation(this->PostProcessingShader.ID, "offsets"), 9, (float*)offsets); int edge_kernel[9] = { -1, -1, -1, -1, 8, -1, -1, -1, -1 }; glUniform1iv(glGetUniformLocation(this->PostProcessingShader.ID, "edge_kernel"), 9, edge_kernel); float blur_kernel[9] = { 1.0f / 16.0f, 2.0f / 16.0f, 1.0f / 16.0f, 2.0f / 16.0f, 4.0f / 16.0f, 2.0f / 16.0f, 1.0f / 16.0f, 2.0f / 16.0f, 1.0f / 16.0f }; glUniform1fv(glGetUniformLocation(this->PostProcessingShader.ID, "blur_kernel"), 9, blur_kernel); } void PostProcessor::BeginRender() { glBindFramebuffer(GL_FRAMEBUFFER, this->MSFBO); glClearColor(0.0f, 0.0f, 0.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); } void PostProcessor::EndRender() { // Теперь помещаем мультисэмплированный цветовой буфер в промежуточный FBO для дальнейшего сохранения в текстуру glBindFramebuffer(GL_READ_FRAMEBUFFER, this->MSFBO); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, this->FBO); glBlitFramebuffer(0, 0, this->Width, this->Height, 0, 0, this->Width, this->Height, GL_COLOR_BUFFER_BIT, GL_NEAREST); glBindFramebuffer(GL_FRAMEBUFFER, 0); // связываем READ-фреймбуфер и WRITE-фреймбуфер с заданным по умолчанием фреймбуфером } void PostProcessor::Render(float time) { // Устанавливаем uniform-переменные/другие_опции this->PostProcessingShader.Use(); this->PostProcessingShader.SetFloat("time", time); this->PostProcessingShader.SetInteger("confuse", this->Confuse); this->PostProcessingShader.SetInteger("chaos", this->Chaos); this->PostProcessingShader.SetInteger("shake", this->Shake); // Рендеринг текстурированного прямоугольника glActiveTexture(GL_TEXTURE0); this->Texture.Bind(); glBindVertexArray(this->VAO); glDrawArrays(GL_TRIANGLES, 0, 6); glBindVertexArray(0); } void PostProcessor::initRenderData() { // Конфигурирование VAO/VBO unsigned int VBO; float vertices[] = { -1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f, -1.0f, -1.0f, 0.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f }; glGenVertexArrays(1, &this->VAO); glGenBuffers(1, &VBO); glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); glBindVertexArray(this->VAO); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindVertexArray(0); } |
Что стоит здесь отметить, так это наличие функций BeginRender() и EndRender(). Поскольку мы должны визуализировать всю игровую сцену во фреймбуфер, то вызовы BeginRender() и EndRender() производятся соответственно до и после кода рендеринга сцены. В результате этого класс берет на себя управление и обработку «внутренними» операциями фреймбуфера. Использование класса PostProcessor внутри функции рендеринга игры будет выглядеть следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
PostProcessor *Effects; void Game::Render() { if (this->State == GAME_ACTIVE) { Effects->BeginRender(); // Отрисовка фона // Отрисовка уровня // Отрисовка игрока // Отрисовка частиц // Отрисовка мяча Effects->EndRender(); Effects->Render(glfwGetTime()); } } |
Теперь мы можем из любого места устанавливать в true
нужное нам свойство класса постобработки, и соответствующий эффект немедленно активируется.
Shake it
В качестве (практической) демонстрации описанных эффектов мы попробуем сымитировать небольшую встряску от столкновения мяча с твердым бетонным блоком, создавая тем самым иллюзию более сильного удара.
Эффект встряхивания экрана должен работать только в течение небольшого периода времени. Мы можем реализовать это, создав переменную под названием ShakeTime
, которая управляет длительностью, в течение которой наш эффект будет активным. При каждом таком столкновении, мы заново устанавливаем для данной переменной временной интервал длительности эффекта:
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 |
float ShakeTime = 0.0f; void Game::DoCollisions() { for (GameObject &box : this->Levels[this->Level].Bricks) { if (!box.Destroyed) { Collision collision = CheckCollision(*Ball, box); if (std::get<0>(collision)) // если произошло столкновение { // разрушаем кирпич (если он не твердый) if (!box.IsSolid) box.Destroyed = true; else { // если же кирпич - твердый, то активируем эффект "Встряски" ShakeTime = 0.05f; Effects->Shake = true; } [...] } } } [...] } |
Затем в функции Game::Update() мы уменьшаем значение переменной ShakeTime
до 0.0f
, тем самым отключая эффект:
1 2 3 4 5 6 7 8 9 10 |
void Game::Update(float dt) { [...] if (ShakeTime > 0.0f) { ShakeTime -= dt; if (ShakeTime <= 0.0f) Effects->Shake = false; } } |
Теперь, каждый раз, когда мяч ударяется о твердый блок, экран ненадолго начинает трястись и немного размываться, давая игроку некоторую зрительную обратную связь: мяч столкнулся с очень твердым объектом.
GitHub / Часть №9: Постобработка в игре «Breakout» на C++/OpenGL — Исходный код