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


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