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

Suzanne, неофициальный маскот Blender, отрендеренный с получившимся шейдером
В этом посте я расскажу, как написать отрисовку контуров с плавным переходом веса линий на OpenGL, хотя метод может использоваться в любом другом графическом API. Всем заинтересованным — добро пожаловать под кат.
Дополнительные библиотеки, которые я буду использовать:
Конфигурационный файл CMakeLists.txt у меня выглядит так:
В целом, все просто — ищем нужные библиотеки и подключаем.
Создадим наше окно:
Теперь загрузим сам OpenGL:
Напишем несколько простых вспомогательных классов, чтобы легко производить освобождение ресурсов в случае ошибок. Правильная их обработка не является целью этого туториала, но даже при падении программы мы будем корректно освобождать ресурсы.
Дополнительные включаемые файлы:
Вспомогательный класс шейдера:
Такой же для шейдерной программы:
А также нам понадобятся вспомогательные классы для:
У всех этих объектов почти одинаковый интерфейс создания/удаления, поэтому можно создать нужные классы с помощью простого макроса.
Указатели на функции OpenGL динамические, поэтому воспользоваться шаблонами не получится.
Создадим шейдерную программу, пока что просто выводящую вершины без пространственных преобразований белым цветом:
s1.vert
s1.frag
Зададим координаты треугольника:
И начнем отрисовывать треугольник в главном цикле:
Добавим вращение камеры вокруг треугольника.
Заголовочный файл glm для векторной математики:
Немного поменяем наш треугольник:
Новый вершинный шейдер будет применять стандартную последовательность преобразований смещение объекта — преобразование координат в пространство камеры — проекция:
Получим ссылки на uniform-переменные шейдера, чтобы заполнять их в программе:
Пусть камера вращается со скоростью 1/8 радиана в секунду, направлена в
и ось «вверх» это Z:
Здесь pos — это положение камеры, а forward, right и up — это левая тройка ортогональных единичных векторов, задающих в пространстве камеры оси Z (от камеры), X (вправо) и Y (вверх) соответственно.
Камера смотрит в
, поэтому вектора pos и forward противонаправлены.
Вектора forward, up и
лежат в одной плоскости, поэтому вектор right можно получить нормированием векторного произведения forward и
(мировая система координат — правая).
Ну и наконец вектор up можно получить как векторное произведение right и forward (эти векторы уже единичные и ортогональные, поэтому их векторное произведение также будет единичным).
Создадим матрицы преобразований:
Никакого смещения в глобальной системе координат не делаем, поэтому матрица модели — единичная.
Матрица вида — это смещение на положение камеры и проекция на оси координат в пространстве камеры, т.е.
Где
— forward,
— right,
— up,
— pos.
Стоит учесть, что в glm матрицы задаются по столбцам, т.е. элементы идут в следующем порядке:
На данном этапе матрица проекции преобразовывает Z из отрезка
(
— zNear,
— zFar) в отрезок
, а также делит X и Y на Z.
Как это работает: вывод gl_Position вершинного шейдера до растеризации делится на свою координату W, поэтому для того, чтобы поделить координаты X и Y на Z мы присваиваем W значение нашей координаты Z. При этом отображаемые координаты XYZ ограничены кубом
, таким образом новая координата Z должна попасть в этот куб. Зададим минимальное —
и максимальное —
значения этой координаты до преобразования, и представим новую Z как линейную комбинацию Z до преобразования и 1 (т.е. W до преобразования):
При этом
должна остаться возрастающей, т.к.
— ближняя граница, а
— дальняя. Отсюда можно составить простую систему уравнений:
И полученная матрица проекции:
Наконец, загрузим наши матрицы в шейдер:
Заменим треугольник на произвольную фигуру. Подключим заголовочные файлы assimp:
Пока будем загружать простой куб. Его OBJ-файл выглядит так:
Здесь просто 8 вершин куба и 6 его граней, по 4 вершины на каждую
Заменим код загрузки вершин в буфер:
И код отрисовки:
А также пока заменим наш фрагментный шейдер, чтобы цвет точки зависил от ее глубины:
Добавим проверку глубины, чтобы было видно не отрисованный позже пиксель, а ближайший к камере:
Также нужно очищать буфер глубины:
Теперь создадим базовую программу постобработки. Идея заключается в том, чтобы отрендерить изображение в текстуру, а затем эту текстуру отрендерить еще раз. Таким образом, мы сможем считывать значения соседних пикселей при отрисовке текстуры, что и будем использовать для проверки глубины при отрисовке контуров.
Схема треугольника, на который отображается текстура:

Здесь черным обозначены экранные координаты, а синим — текстурные.
Как можно заметить, в черный квадрат экрана (координаты от -1 до 1) попадают точки текстуры с координатами от 0 до 1.
Создадим еще один буфер и массив вершин — для треугольника; две текстуры — для отрисовки цвета и глубины; фреймбуфер — для обозначения, куда мы хотим отрисовывать.
Загружаем координаты треугольника:
Создадим текстуры:
Чтобы не пересоздавать текстуры каждый раз при изменении размеров окна, создадим их с запасом по размеру (у меня монитор 1920 на 1080, поэтому 2048 на 2048 — достаточный запас), и будем домножать текстурные координаты на коэффициент
Создаем фреймбуфер:
И создаем программу постобработки:
s2.vert
s2.frag
Как можно заметить, пока суть такой постобработки просто в инвертировании зеленого канала.
Прицепим текстуру цвета в слот 0, а текстуру глубины — в слот 1.
Запомним положение переменной, отвечающей за масштаб текстуры:
Новый главный цикл:
Вернем отображение всех треугольников белым цветом, т.к. больше их цвет нам не понадобится
s1.frag
Наконец, все приготовления закончены, и можно заниматься самим шейдером постобработки.
s2.frag
Здесь мы сравниваем глубину обрабатываемого пикселя и 4 соседних, и в зависимости от нее устанавливаем цвет пикселя на мониторе. Причем если пиксель находится внутри треугольника, то значение d будет равно 0: глубина линейно зависит от координат X и Y, поэтому сумма значений на концах отрезка равна удвоенной сумме в середине отрезка, и разность этих значений, соответственно, выдаст 0. Отрезков у нас 2:
и
, и не на границах треугольника влияние каждого из них на d будет нулевым.
Как можно заметить, границ мы на самом деле обнаруживаем 2: внутреннюю (цвет темнее белого, d > 0) и внешнюю (белую, d < 0). Двойная граница это конечно классно, и, может быть, кто-то хочет воспользоваться именно таким стилем, но я хотел бы пойти дальше.
Самое простое решение — взять модуль от d. Тогда мы увидим двойную темную границу:
Заодно установим светло-серый фон, т.к. темные контуры на синем фоне уже не особо видны:
Заменим модельку куба на что-нибудь поинтереснее, например Suzanne из Blender, а заодно поменяем ракурс:
Теперь хотелось бы сделать линии толщиной не 2, а 1 пиксель. Самое простое, что приходит в голову — отрендерить текстуру до постобработки в 2 раза большего размера:
Но такой подход требует в 4 раза больше времени на растеризацию до постобработки. Вместо этого можно, например, рисовать только один из двух контуров (внешний и внутренний):
Основной недостаток этих методов в том, что они неодинаково обрабатывают выпуклые и вогнутые грани, что можно видеть на модели Suzanne. Но, тем не менее, они выдают линию толщиной 1 пиксель без отрисовки изображения удвоенного разрешения.
В этом туториале мы прошлись по пути от создания окна до шейдера постобработки с уникальным стилем. Надеюсь, кому-нибудь такая постобработка покажется интересной, и найдет применение в уникальном стиле игры или анимации.
Весь код туториала доступен на GitHub

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 радиана в секунду, направлена в
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 (вверх) соответственно.
Камера смотрит в
Вектора forward, up и
Ну и наконец вектор 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);
Матрица вида — это смещение на положение камеры и проекция на оси координат в пространстве камеры, т.е.
Где
Стоит учесть, что в glm матрицы задаются по столбцам, т.е. элементы идут в следующем порядке:
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 из отрезка
Как это работает: вывод gl_Position вершинного шейдера до растеризации делится на свою координату W, поэтому для того, чтобы поделить координаты X и Y на Z мы присваиваем W значение нашей координаты Z. При этом отображаемые координаты XYZ ограничены кубом
При этом
И полученная матрица проекции:
Наконец, загрузим наши матрицы в шейдер:
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 — достаточный запас), и будем домножать текстурные координаты на коэффициент
Создаем фреймбуфер:
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:
Результат


Как можно заметить, границ мы на самом деле обнаруживаем 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
