Простой шейдер мультяшной графики в OpenGL своими руками

от автора

Недавно я заинтересовался рисунками, состоящими только из контуров, и решил попытаться воспроизвести нечто подобное для трехмерной графики.


Suzanne, неофициальный маскот Blender, отрендеренный с получившимся шейдером

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

Дополнительные библиотеки, которые я буду использовать:

  • glfw — для создания окна и обработки событий
  • glew — для загрузки функций OpenGL
  • glm — для векторной и матричной математики
  • assimp — для загрузки модели

Конфигурационный файл CMakeLists.txt у меня выглядит так:

cmake_minimum_required(VERSION 3.17) project(OpenGL_posteffect_tutorial)  find_package(GLEW REQUIRED) find_package(OpenGL REQUIRED) find_package(glfw3 REQUIRED) find_package(glm REQUIRED) find_package(assimp REQUIRED)  add_executable(main main.cpp) target_link_libraries(main GLEW::GLEW OpenGL glfw glm assimp) 

В целом, все просто — ищем нужные библиотеки и подключаем.

Шаг 1. Создание окна

Создадим наше окно:

#include <GLFW/glfw3.h> #include <cstdio> #include <functional>  // Вспомогательный класс, чтобы описать // освобождение ресурсов сразу после их выделения class InvokeOnDestroy {   std::function<void()> f;  public:   InvokeOnDestroy(std::function<void()> &&fn) : f(fn) {}   ~InvokeOnDestroy() { f(); } };  // В целях отладки будем выводить сообщения glfw об ошибках void myGlfwErrorCallback(int code, const char *description) {   printf("[GLFW] %d: %s\n", code, description);   fflush(stdout); }  // Будем закрывать приложение по нажатию на Escape void myGlfwKeyCallback(GLFWwindow *window, int key, int scancode, int action,                        int mods) {   if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)     glfwSetWindowShouldClose(window, GLFW_TRUE); }  int main() {   if (!glfwInit())     return __LINE__;   InvokeOnDestroy _glfwTerminate(glfwTerminate);   glfwSetErrorCallback(myGlfwErrorCallback);    GLFWwindow *window = glfwCreateWindow(640, 360, "OpenGL Tutorial", nullptr, nullptr);   glfwSetKeyCallback(window, myGlfwKeyCallback);    // Основной цикл   while (!glfwWindowShouldClose(window)) {     glfwPollEvents();     glfwSwapBuffers(window);   }    return 0; }

Результат


Черный экран. Ожидаемо, ведь мы пока ничего и не рисуем

Шаг 2. Загрузка OpenGL

Теперь загрузим сам OpenGL:

// GLEW обязательно включать до GLFW #include <GL/glew.h> #include <GLFW/glfw3.h> #include <cstdio> #include <functional>  class InvokeOnDestroy {   std::function<void()> f;  public:   InvokeOnDestroy(std::function<void()> &&fn) : f(fn) {}   ~InvokeOnDestroy() { f(); } };  void myGlfwErrorCallback(int code, const char *description) {   printf("[GLFW][code=%d] %s\n", code, description);   fflush(stdout); }  void myGlfwKeyCallback(GLFWwindow *window, int key, int scancode, int action,                        int mods) {   if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)     glfwSetWindowShouldClose(window, GLFW_TRUE); }  // Создадим обработчик для отладочного вывода самого OpenGL void GLAPIENTRY myGlDebugCallback(GLenum source, GLenum type, GLuint id,                                   GLenum severity, GLsizei length,                                   const GLchar *message,                                   const void *userParam) {   printf("[GL][source=0x%X; type=0x%X; id=0x%X; severity=0x%X] %s\n", source,          type, id, severity, message); }  int main() {   if (!glfwInit())     return __LINE__;   InvokeOnDestroy _glfwTerminate(glfwTerminate);   glfwSetErrorCallback(myGlfwErrorCallback);    GLFWwindow *window =       glfwCreateWindow(800, 600, "OpenGL Tutorial", nullptr, nullptr);   glfwSetKeyCallback(window, myGlfwKeyCallback);    // Загрузка OpenGL   glfwMakeContextCurrent(window);   if (glewInit() != GLEW_OK)     return __LINE__;    // Привязка отладчика   glEnable(GL_DEBUG_OUTPUT);   glDebugMessageCallback(myGlDebugCallback, nullptr);    // Будем закрашивать окно, например, синим цветом   glClearColor(0.0f, 0.0f, 1.0f, 0.0f);   while (!glfwWindowShouldClose(window)) {     glfwPollEvents();     glClear(GL_COLOR_BUFFER_BIT);     glfwSwapBuffers(window);   }    return 0; }

Результат


Теперь фон синий

Шаг 3. Отрисовка треугольника

Напишем несколько простых вспомогательных классов, чтобы легко производить освобождение ресурсов в случае ошибок. Правильная их обработка не является целью этого туториала, но даже при падении программы мы будем корректно освобождать ресурсы.

Дополнительные включаемые файлы:

#include <exception> #include <string> #include <vector>

Вспомогательный класс шейдера:

class Shader {   // Идентификатор объекта OpenGL   GLuint id;    // Загрузка исходного кода из файла   void load(const char *filename) {     FILE *f = fopen(filename, "r");     // В случае неудачи выбросим исключение     // Корректная обработка ошибок не входит в цели этого туториала     // Поэтому здесь ей можно пренебречь     if (!f)       throw std::exception();     InvokeOnDestroy _fclose([&]() { fclose(f); });      // Читаем содержимое файла     std::string src;     int c;     while ((c = getc(f)) != EOF)       src.push_back(c);      // Загружаем код шейдера     const GLchar *string = src.data();     const GLint length = src.length();     glShaderSource(id, 1, &string, &length);   }    // Компиляция шейдера   void compile() {     glCompileShader(id);     // Проверяем успешность компиляции     GLint status;     glGetShaderiv(id, GL_COMPILE_STATUS, &status);     if (!status) {       // В случае неудачи -- выведем сообщение компилятора       // и выбросим исключение       GLchar infoLog[2048];       GLsizei length;       glGetShaderInfoLog(id, sizeof(infoLog) / sizeof(infoLog[0]), &length,                          infoLog);       fputs(infoLog, stderr);       fflush(stderr);       throw std::exception();     }   }  public:   Shader(GLenum type) : id(glCreateShader(type)) {}   ~Shader() { glDeleteShader(id); }   // Оператор преобразования к GLuint,   // чтобы можно было вызывать функции OpenGL   // прямо от нашего объекта   operator GLuint() { return id; }    // Вынесено в отдельную функцию,   // т.к. в случае исключения в конструкторе   // деструктор не вызывается   void init(const char *filename) {     load(filename);     compile();   } };

Такой же для шейдерной программы:

class ShaderProgram {   // Идентификатор объекта OpenGL   GLuint id;    // Компоновка программы   void link() {     glLinkProgram(id);     // Проверяем успешность     GLint status;     glGetProgramiv(id, GL_LINK_STATUS, &status);     if (!status) {       // В случае неудачи -- выведем сообщение компоновщика       // и выбросим исключение       GLchar infoLog[2048];       GLsizei length;       glGetProgramInfoLog(id, sizeof(infoLog) / sizeof(infoLog[0]), &length,                           infoLog);       fputs(infoLog, stderr);       fflush(stderr);       throw std::exception();     }   }    // Валидация программы   void validate() {     glValidateProgram(id);     // Проверяем успешность     GLint status;     glGetProgramiv(id, GL_VALIDATE_STATUS, &status);     if (!status) {       // В случае неудачи -- выведем сообщение валидатора       // и выбросим исключение       GLchar infoLog[2048];       GLsizei length;       glGetProgramInfoLog(id, sizeof(infoLog) / sizeof(infoLog[0]), &length,                           infoLog);       fputs(infoLog, stderr);       fflush(stderr);       throw std::exception();     }   }  public:   ShaderProgram() : id(glCreateProgram()) {}   ~ShaderProgram() { glDeleteProgram(id); }   operator GLuint() { return id; }    // Вынесено в отдельную функцию,   // т.к. в случае исключения в конструкторе   // деструктор не вызывается   void init(const char *vertSrc, const char *fragSrc) {     // Создадим вершинный и фрагментный шейдеры     Shader vert(GL_VERTEX_SHADER);     Shader frag(GL_FRAGMENT_SHADER);     vert.init(vertSrc);     frag.init(fragSrc);     // Присоединим их к программе     glAttachShader(id, vert);     glAttachShader(id, frag);     // Скомпонуем и проверим программу     link();     validate();   } };

А также нам понадобятся вспомогательные классы для:

  • Буферов
  • Массивов вершин
  • Текстур
  • Фреймбуферов

У всех этих объектов почти одинаковый интерфейс создания/удаления, поэтому можно создать нужные классы с помощью простого макроса.

#define DEFINE_GL_ARRAY_HELPER(name, gen, del)                                 \   struct name : public std::vector<GLuint> {                                   \     name(size_t n) : std::vector<GLuint>(n) { gen(n, data()); }                \     ~name() { del(size(), data()); }                                           \   }; DEFINE_GL_ARRAY_HELPER(Buffers, glGenBuffers, glDeleteBuffers) DEFINE_GL_ARRAY_HELPER(VertexArrays, glGenVertexArrays, glDeleteVertexArrays) DEFINE_GL_ARRAY_HELPER(Textures, glGenTextures, glDeleteTextures) DEFINE_GL_ARRAY_HELPER(Framebuffers, glGenFramebuffers, glDeleteFramebuffers)

Указатели на функции OpenGL динамические, поэтому воспользоваться шаблонами не получится.

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

ShaderProgram mainProgram; mainProgram.init("s1.vert", "s1.frag");

s1.vert

#version 330 core  in vec3 vertexPosition;  void main() {   gl_Position = vec4(vertexPosition, 1); }

s1.frag

#version 330 core  out vec4 pixelColor;  void main() {   pixelColor = vec4(1); }

Зададим координаты треугольника:

Buffers buffers(1); VertexArrays vertexArrays(1); GLint attribLocation;  glBindVertexArray(vertexArrays[0]); glBindBuffer(GL_ARRAY_BUFFER, buffers[0]); // Заполним буфер координатами точек треугольника GLfloat vertices[] = {     -0.5f, -0.5f, 0.0f,     -0.5f, 0.5f,  0.0f,     0.5f,  0.0f,  0.0f, }; glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // Подключим буфер как вход вершинного шейдера attribLocation = glGetAttribLocation(mainProgram, "vertexPosition"); glEnableVertexAttribArray(attribLocation); // Зададим использование трех координат на вершину с плотной упаковкой glVertexAttribPointer(attribLocation, 3, GL_FLOAT, GL_FALSE, 0, 0); glBindVertexArray(0); glBindBuffer(GL_ARRAY_BUFFER, 0);

И начнем отрисовывать треугольник в главном цикле:

while (!glfwWindowShouldClose(window)) {   glfwPollEvents();    int framebufferWidth, framebufferHeight;   glfwGetFramebufferSize(window, &framebufferWidth, &framebufferHeight);   glViewport(0, 0, framebufferWidth, framebufferHeight);    glClear(GL_COLOR_BUFFER_BIT);   glBindVertexArray(vertexArrays[0]);   glUseProgram(mainProgram);   // Отрисовываем треугольник из начала буфера и трех вершин   glDrawArrays(GL_TRIANGLES, 0, 3);   glUseProgram(0);   glBindVertexArray(0);    glfwSwapBuffers(window); }

Результат

Шаг 4. Перемещение камеры

Добавим вращение камеры вокруг треугольника.

Заголовочный файл glm для векторной математики:

#include <glm/glm.hpp>

Немного поменяем наш треугольник:

GLfloat vertices[] = {     0.0f, 0.0f, 1.0f,     0.0f, 1.0f, 0.0f,     1.0f, 0.0f, 0.0f, };

Новый вершинный шейдер будет применять стандартную последовательность преобразований смещение объекта — преобразование координат в пространство камеры — проекция:

#version 330 core  uniform mat4 matModel; uniform mat4 matView; uniform mat4 matProjection; in vec3 vertexPosition;  void main() {   gl_Position = matProjection * matView * matModel * vec4(vertexPosition, 1); }

Получим ссылки на uniform-переменные шейдера, чтобы заполнять их в программе:

GLint ulMatModel = glGetUniformLocation(mainProgram, "matModel"); GLint ulMatView = glGetUniformLocation(mainProgram, "matView"); GLint ulMatProjection = glGetUniformLocation(mainProgram, "matProjection");

Пусть камера вращается со скоростью 1/8 радиана в секунду, направлена в $\vec{0}$ и ось «вверх» это Z:

float angle = 0.5f * glfwGetTime(); float sin = glm::sin(angle); float cos = glm::cos(angle); glm::vec3 pos(2.5f * sin, 2.5f * cos, 1.5f); glm::vec3 forward = glm::normalize(-pos); glm::vec3 up(0.0f, 0.0f, 1.0f); glm::vec3 right = glm::normalize(glm::cross(forward, up)); up = glm::cross(right, forward);

Здесь pos — это положение камеры, а forward, right и up — это левая тройка ортогональных единичных векторов, задающих в пространстве камеры оси Z (от камеры), X (вправо) и Y (вверх) соответственно.

Камера смотрит в $\vec{0}$, поэтому вектора pos и forward противонаправлены.
Вектора forward, up и $(0,0,1)$ лежат в одной плоскости, поэтому вектор right можно получить нормированием векторного произведения forward и $(0,0,1)$ (мировая система координат — правая).

Ну и наконец вектор up можно получить как векторное произведение right и forward (эти векторы уже единичные и ортогональные, поэтому их векторное произведение также будет единичным).

Создадим матрицы преобразований:

glm::mat4 matModel(1.0f, 0.0f, 0.0f, 0.0f,                     0.0f, 1.0f, 0.0f, 0.0f,                     0.0f, 0.0f, 1.0f, 0.0f,                     0.0f, 0.0f, 0.0f, 1.0f); 

Никакого смещения в глобальной системе координат не делаем, поэтому матрица модели — единичная.

glm::mat4 matView(right.x, up.x, forward.x, 0.0f,                   right.y, up.y, forward.y, 0.0f,                   right.z, up.z, forward.z, 0.0f,                   0.0f, 0.0f, 0.0f, 1.0f); matView *= glm::mat4(1.0f, 0.0f, 0.0f, 0.0f,                       0.0f, 1.0f, 0.0f, 0.0f,                       0.0f, 0.0f, 1.0f, 0.0f,                       -pos.x, -pos.y, -pos.z, 1.0f);

Матрица вида — это смещение на положение камеры и проекция на оси координат в пространстве камеры, т.е.

$M=\left[\begin{array}{cccc} R_x & R_y & R_z & 0 \\ U_x & U_y & U_z & 0 \\ F_x & F_y & F_z & 0 \\ 0 & 0 & 0 & 1 \\ \end{array}\right]\cdot\left[\begin{array}{cccc} 1 & 0 & 0 & -P_x \\ 0 & 1 & 0 & -P_y \\ 0 & 0 & 1 & -P_z \\ 0 & 0 & 0 & 1 \\ \end{array}\right]$

Где $\vec{F}$ — forward, $\vec{R}$ — right, $\vec{U}$ — up, $\vec{P}$ — pos.

Стоит учесть, что в glm матрицы задаются по столбцам, т.е. элементы идут в следующем порядке:

$A=\left[\begin{array}{cccc} a_1 & a_5 & a_9 & a_{13} \\ a_2 & a_6 & a_{10} & a_{14} \\ a_3 & a_7 & a_{11} & a_{15} \\ a_4 & a_8 & a_{12} & a_{16} \\ \end{array}\right]$

float zNear = 0.0625f; float zFar = 32.0f; glm::mat4 matProjection(1.0f, 0.0f, 0.0f, 0.0f,                         0.0f, 1.0f, 0.0f, 0.0f,                         0.0f, 0.0f, (zFar + zNear) / (zFar - zNear), 1.0f,                         0.0f, 0.0f, -2.0f * zFar * zNear / (zFar - zNear), 0.0f);

На данном этапе матрица проекции преобразовывает Z из отрезка $[z_1;z_2]$ ($z_1$ — zNear, $z_2$ — zFar) в отрезок $[-1;1]$, а также делит X и Y на Z.

Как это работает: вывод gl_Position вершинного шейдера до растеризации делится на свою координату W, поэтому для того, чтобы поделить координаты X и Y на Z мы присваиваем W значение нашей координаты Z. При этом отображаемые координаты XYZ ограничены кубом $[-1;1]^3$, таким образом новая координата Z должна попасть в этот куб. Зададим минимальное — $z_1$ и максимальное — $z_2$ значения этой координаты до преобразования, и представим новую Z как линейную комбинацию Z до преобразования и 1 (т.е. W до преобразования):

$\hat{z}=\frac{az+b}{z}=a+\frac{b}{z}; z_1\leq z\leq z_2; -1\leq\hat{z}\leq 1$

При этом $z$ должна остаться возрастающей, т.к. $z_1$ — ближняя граница, а $z_2$ — дальняя. Отсюда можно составить простую систему уравнений:

$\left\{\begin{array}{ccc} a+\frac{b}{z_1}&=&-1 \\ a+\frac{b}{z_2}&=&1 \\ \end{array}\right.$

$\left\{\begin{array}{l} a\left(z_2-z_1\right)=z_2+z_1 \\ b\left(\frac{1}{z_2}-\frac{1}{z_1}\right)=2 \\ \end{array}\right.$

$a=\frac{z_2+z_1}{z_2-z_1}$

$b=\frac{2z_1z_2}{z_1-z_2}=-2\frac{z_2z_1}{z_2-z_1}$

И полученная матрица проекции:

$M=\left[\begin{array}{cccc} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & \frac{z_2+z_1}{z_2-z_1} & -2\frac{z_2z_1}{z_2-z_1} \\ 0 & 0 & 1 & 0 \\ \end{array}\right]$

Наконец, загрузим наши матрицы в шейдер:

glUseProgram(mainProgram); glUniformMatrix4fv(ulMatModel,      1, GL_FALSE, &matModel[0][0]); glUniformMatrix4fv(ulMatView,       1, GL_FALSE, &matView[0][0]); glUniformMatrix4fv(ulMatProjection, 1, GL_FALSE, &matProjection[0][0]); glDrawArrays(GL_TRIANGLES, 0, 3);
Результат


Как и ожидалось, просто вращающийся белый треугольник. Пока ничего интересного.

Шаг 5. Загрузка произвольной модели

Заменим треугольник на произвольную фигуру. Подключим заголовочные файлы assimp:

#include <assimp/Importer.hpp> #include <assimp/postprocess.h> #include <assimp/scene.h>

Пока будем загружать простой куб. Его OBJ-файл выглядит так:

v  1  1  1 v  1  1 -1 v  1 -1  1 v  1 -1 -1 v -1  1  1 v -1  1 -1 v -1 -1  1 v -1 -1 -1 f 1 5 7 3 f 4 3 7 8 f 8 7 5 6 f 6 2 4 8 f 2 1 3 4 f 6 5 1 2

Здесь просто 8 вершин куба и 6 его граней, по 4 вершины на каждую

Заменим код загрузки вершин в буфер:

// Нам потребуется 2 буфера: для вершин и для индексов вершин Buffers buffers(2); VertexArrays vertexArrays(1); GLint attribLocation;  glBindVertexArray(vertexArrays[0]); glBindBuffer(GL_ARRAY_BUFFER, buffers[0]); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buffers[1]); // В объекте произвольное количество вершин, поэтому запишем его в переменную GLuint indexCount; {   Assimp::Importer importer;   // Грани могут быть записаны не в виде треугольников,   // поэтому произведем триангуляцию при загрузке   const aiScene *scene =       importer.ReadFile("scene.obj", aiProcess_Triangulate);   // Пока считаем, что у нас только один объект в сцене   const aiMesh *mesh = scene->mMeshes[0];   glBufferData(GL_ARRAY_BUFFER, mesh->mNumVertices * 3 * sizeof(GLfloat),                 mesh->mVertices, GL_STATIC_DRAW);   // Проходим по всем граням и запоминаем индексы вершин треугольников   std::vector<GLuint> indices;   for (int i = 0; i < mesh->mNumFaces; ++i)     for (int j = 0; j < mesh->mFaces[i].mNumIndices; ++j)       indices.push_back(mesh->mFaces[i].mIndices[j]);   // Запоминаем количество индексов   indexCount = indices.size();   // Загружаем индексы в буфер   glBufferData(GL_ELEMENT_ARRAY_BUFFER, indexCount * sizeof(GLuint),                 indices.data(), GL_STATIC_DRAW); } // Здесь ничего не меняется, на вход вершинного шейдера по-прежнему подаются // трехмерные вектора вещественных чисел одинарной точности attribLocation = glGetAttribLocation(mainProgram, "vertexPosition"); glEnableVertexAttribArray(attribLocation); glVertexAttribPointer(attribLocation, 3, GL_FLOAT, GL_FALSE, 0, 0); glBindVertexArray(0); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);

И код отрисовки:

// Заменяем glDrawArrays(GL_TRIANGLES, 0, 3) на glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_INT, 0);

А также пока заменим наш фрагментный шейдер, чтобы цвет точки зависил от ее глубины:

#version 330 core  out vec4 pixelColor;  void main() {   pixelColor = vec4(vec3(exp(-gl_FragCoord.w)), 1); }

Результат


Что-то тут не так. Ведь глубина на поверхности куба должна быть непрерывной.

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

glEnable(GL_DEPTH_TEST);

Также нужно очищать буфер глубины:

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
Результат


Вот теперь порядок

Шаг 6. Постобработка

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

Схема треугольника, на который отображается текстура:

Здесь черным обозначены экранные координаты, а синим — текстурные.

Как можно заметить, в черный квадрат экрана (координаты от -1 до 1) попадают точки текстуры с координатами от 0 до 1.

Создадим еще один буфер и массив вершин — для треугольника; две текстуры — для отрисовки цвета и глубины; фреймбуфер — для обозначения, куда мы хотим отрисовывать.

Buffers buffers(3); VertexArrays vertexArrays(2); Textures textures(2); Framebuffers framebuffers(1);

Загружаем координаты треугольника:

glBindVertexArray(vertexArrays[1]); glBindBuffer(GL_ARRAY_BUFFER, buffers[2]); GLfloat fillTriangle[] = {     -1.0f, -1.0f, 0.0f, 0.0f, //     3.0f,  -1.0f, 2.0f, 0.0f, //     -1.0f, 3.0f,  0.0f, 2.0f, // }; glBufferData(GL_ARRAY_BUFFER, sizeof(fillTriangle), fillTriangle,               GL_STATIC_DRAW); attribLocation = glGetAttribLocation(postProgram, "vertexPosition"); glEnableVertexAttribArray(attribLocation); glVertexAttribPointer(attribLocation, 2, GL_FLOAT, GL_FALSE,                       4 * sizeof(GLfloat), 0); attribLocation = glGetAttribLocation(postProgram, "vertexTextureCoords"); glEnableVertexAttribArray(attribLocation); glVertexAttribPointer(attribLocation, 2, GL_FLOAT, GL_FALSE,                       4 * sizeof(GLfloat), (GLvoid *)(2 * sizeof(GLfloat))); 

Создадим текстуры:

const int MAX_WIDTH = 2048; const int MAX_HEIGHT = 2048; glBindTexture(GL_TEXTURE_2D, textures[0]); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, MAX_WIDTH, MAX_HEIGHT, 0, GL_RGB,               GL_UNSIGNED_BYTE, nullptr); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);  glBindTexture(GL_TEXTURE_2D, textures[1]); glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, MAX_WIDTH, MAX_HEIGHT, 0,               GL_DEPTH_COMPONENT, GL_FLOAT, nullptr); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glBindTexture(GL_TEXTURE_2D, 0);

Чтобы не пересоздавать текстуры каждый раз при изменении размеров окна, создадим их с запасом по размеру (у меня монитор 1920 на 1080, поэтому 2048 на 2048 — достаточный запас), и будем домножать текстурные координаты на коэффициент

$\frac{\text{ширина или высота окна}}{\text{ширина или высота текстуры}}$

Создаем фреймбуфер:

glBindFramebuffer(GL_FRAMEBUFFER, framebuffers[0]); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,                         textures[0], 0); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D,                         textures[1], 0); GLenum drawBuffers[] = {GL_COLOR_ATTACHMENT0}; glDrawBuffers(sizeof(drawBuffers) / sizeof(drawBuffers[0]), drawBuffers); glBindFramebuffer(GL_FRAMEBUFFER, 0);

И создаем программу постобработки:

ShaderProgram postProgram; postProgram.init("s2.vert", "s2.frag");

s2.vert

#version 330 core  uniform vec2 textureScale; in vec2 vertexPosition; in vec2 vertexTextureCoords; out vec2 textureCoords;  void main() {   gl_Position = vec4(vertexPosition, 0, 1);   textureCoords = textureScale * vertexTextureCoords; }

s2.frag

#version 330 core  uniform sampler2D renderTexture; uniform sampler2D depthTexture; in vec2 textureCoords; out vec4 pixelColor;  void main() {   vec4 baseColor = texture2D(renderTexture, textureCoords);   pixelColor = vec4(baseColor.x, 1 - baseColor.y, baseColor.z, 1); }

Как можно заметить, пока суть такой постобработки просто в инвертировании зеленого канала.

Прицепим текстуру цвета в слот 0, а текстуру глубины — в слот 1.

glBindVertexArray(vertexArrays[1]); glUseProgram(postProgram); glUniform1i(glGetUniformLocation(postProgram, "renderTexture"), 0); glUniform1i(glGetUniformLocation(postProgram, "depthTexture"), 1); glUseProgram(0); glBindVertexArray(0);

Запомним положение переменной, отвечающей за масштаб текстуры:

GLint ulTextureScale = glGetUniformLocation(postProgram, "textureScale");

Новый главный цикл:

while (!glfwWindowShouldClose(window)) {   glfwPollEvents();    int framebufferWidth, framebufferHeight;   glfwGetFramebufferSize(window, &framebufferWidth, &framebufferHeight);   glViewport(0, 0, framebufferWidth, framebufferHeight);    // Сначала отрисовываем фреймбуфер   glBindFramebuffer(GL_FRAMEBUFFER, framebuffers[0]);   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);   glBindVertexArray(vertexArrays[0]);   glUseProgram(mainProgram);    // Здесь ничего не поменялось   float angle = 0.5f * glfwGetTime();   float sin = glm::sin(angle);   float cos = glm::cos(angle);   glm::vec3 pos(2.5f * sin, 2.5f * cos, 1.5f);   glm::vec3 forward = glm::normalize(-pos);   glm::vec3 up(0.0f, 0.0f, 1.0f);   glm::vec3 right = glm::normalize(glm::cross(forward, up));   up = glm::normalize(glm::cross(right, forward));   float zNear = 0.0625f;   float zFar = 32.0f;    glm::mat4 matModel(1.0f, 0.0f, 0.0f, 0.0f,                       0.0f, 1.0f, 0.0f, 0.0f,                       0.0f, 0.0f, 1.0f, 0.0f,                       0.0f, 0.0f, 0.0f, 1.0f);   glm::mat4 matView(right.x, up.x, forward.x, 0.0f,                     right.y, up.y, forward.y, 0.0f,                     right.z, up.z, forward.z, 0.0f,                     0.0f, 0.0f, 0.0f, 1.0f);   matView *= glm::mat4(1.0f, 0.0f, 0.0f, 0.0f,                         0.0f, 1.0f, 0.0f, 0.0f,                         0.0f, 0.0f, 1.0f, 0.0f,                         -pos.x, -pos.y, -pos.z, 1.0f);   glm::mat4 matProjection(       (float)framebufferHeight / framebufferWidth, 0.0f, 0.0f, 0.0f,       0.0f, 1.0f, 0.0f, 0.0f,       0.0f, 0.0f, (zFar + zNear) / (zFar - zNear), 1.0f,       0.0f, 0.0f, -2.0f * zFar * zNear / (zFar - zNear), 0.0f);    glUniformMatrix4fv(ulMatModel, 1, GL_FALSE, &matModel[0][0]);   glUniformMatrix4fv(ulMatView, 1, GL_FALSE, &matView[0][0]);   glUniformMatrix4fv(ulMatProjection, 1, GL_FALSE, &matProjection[0][0]);    glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_INT, 0);    // Теперь отрисуем получившуюся текстуру   glBindFramebuffer(GL_FRAMEBUFFER, 0);   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);   // В слоте 0 -- текстура цвета   glBindTexture(GL_TEXTURE_2D, textures[0]);   // В слоте 1 -- текстура глубины   glActiveTexture(GL_TEXTURE1);   glBindTexture(GL_TEXTURE_2D, textures[1]);   glBindVertexArray(vertexArrays[1]);   glUseProgram(postProgram);   // Масштаб текстуры   glUniform2f(ulTextureScale,               (GLfloat)framebufferWidth / MAX_WIDTH,               (GLfloat)framebufferHeight / MAX_HEIGHT);   glDrawArrays(GL_TRIANGLES, 0, 3);   glBindTexture(GL_TEXTURE_2D, 0);   glActiveTexture(GL_TEXTURE0);   glBindTexture(GL_TEXTURE_2D, 0);    glfwSwapBuffers(window); }

Результат


Зеленый канал инвертирован

Шаг 7. Отрисовка контуров

Вернем отображение всех треугольников белым цветом, т.к. больше их цвет нам не понадобится

s1.frag

#version 330 core  out vec4 pixelColor;  void main() {   pixelColor = vec4(1); }

Наконец, все приготовления закончены, и можно заниматься самим шейдером постобработки.
s2.frag

#version 330 core  uniform vec2 reverseMaxSize; uniform sampler2D renderTexture; uniform sampler2D depthTexture; in vec2 textureCoords; out vec4 pixelColor;  void main() {   vec4 baseColor = texture2D(renderTexture, textureCoords);   float sum = 0.0f;   float my = texture2D(depthTexture, textureCoords).x;   sum += texture2D(depthTexture, textureCoords + vec2(+1, 0) * reverseMaxSize).x;   sum += texture2D(depthTexture, textureCoords + vec2(-1, 0) * reverseMaxSize).x;   sum += texture2D(depthTexture, textureCoords + vec2(0, +1) * reverseMaxSize).x;   sum += texture2D(depthTexture, textureCoords + vec2(0, -1) * reverseMaxSize).x;   float d = sum / my - 4.0f;   pixelColor = baseColor - vec4(1000.0f * d, 100.0f * d, 10.0f * d, 0); }

Здесь мы сравниваем глубину обрабатываемого пикселя и 4 соседних, и в зависимости от нее устанавливаем цвет пикселя на мониторе. Причем если пиксель находится внутри треугольника, то значение d будет равно 0: глубина линейно зависит от координат X и Y, поэтому сумма значений на концах отрезка равна удвоенной сумме в середине отрезка, и разность этих значений, соответственно, выдаст 0. Отрезков у нас 2: $-1<\Delta x<1$ и $-1<\Delta y<1$, и не на границах треугольника влияние каждого из них на d будет нулевым.

Результат

Как можно заметить, границ мы на самом деле обнаруживаем 2: внутреннюю (цвет темнее белого, d > 0) и внешнюю (белую, d < 0). Двойная граница это конечно классно, и, может быть, кто-то хочет воспользоваться именно таким стилем, но я хотел бы пойти дальше.

Самое простое решение — взять модуль от d. Тогда мы увидим двойную темную границу:

float d = abs(sum / my - 4.0f);

Заодно установим светло-серый фон, т.к. темные контуры на синем фоне уже не особо видны:

glClearColor(0.875f, 0.875f, 0.875f, 0.0f);

Результат

Заменим модельку куба на что-нибудь поинтереснее, например Suzanne из Blender, а заодно поменяем ракурс:

float angle = 0.125f * glfwGetTime(); float sin = glm::sin(angle); float cos = glm::cos(angle); glm::vec3 pos(2.0f * sin, 2.0f * cos, 0.125f);

Результат

Теперь хотелось бы сделать линии толщиной не 2, а 1 пиксель. Самое простое, что приходит в голову — отрендерить текстуру до постобработки в 2 раза большего размера:

const int MAX_WIDTH = 4096; const int MAX_HEIGHT = 4096;

glBindFramebuffer(GL_FRAMEBUFFER, framebuffers[0]); glViewport(0, 0, 2 * framebufferWidth, 2 * framebufferHeight);

glBindFramebuffer(GL_FRAMEBUFFER, 0); glViewport(0, 0, framebufferWidth, framebufferHeight);

glUniform2f(ulTextureScale,             2.0f * framebufferWidth / MAX_WIDTH,             2.0f * framebufferHeight / MAX_HEIGHT);

Результат

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

float d = max(0.0f, sum / my - 4.0f);

Результат

float d = max(0.0f, 4.0f - sum / my);

Результат

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

Заключение

В этом туториале мы прошлись по пути от создания окна до шейдера постобработки с уникальным стилем. Надеюсь, кому-нибудь такая постобработка покажется интересной, и найдет применение в уникальном стиле игры или анимации.

Весь код туториала доступен на GitHub

ссылка на оригинал статьи https://habr.com/ru/post/505726/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *