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 радиана в секунду, направлена в и ось «вверх» это 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 (вверх) соответственно.
Камера смотрит в , поэтому вектора pos и forward противонаправлены.
Вектора forward, up и лежат в одной плоскости, поэтому вектор right можно получить нормированием векторного произведения forward и (мировая система координат — правая).
Ну и наконец вектор 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);
Матрица вида — это смещение на положение камеры и проекция на оси координат в пространстве камеры, т.е.
Где — forward, — right, — up, — pos.
Стоит учесть, что в 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 из отрезка ( — zNear, — zFar) в отрезок , а также делит X и Y на Z.
Как это работает: вывод gl_Position вершинного шейдера до растеризации делится на свою координату W, поэтому для того, чтобы поделить координаты X и Y на Z мы присваиваем W значение нашей координаты Z. При этом отображаемые координаты XYZ ограничены кубом , таким образом новая координата Z должна попасть в этот куб. Зададим минимальное — и максимальное — значения этой координаты до преобразования, и представим новую Z как линейную комбинацию Z до преобразования и 1 (т.е. W до преобразования):
При этом должна остаться возрастающей, т.к. — ближняя граница, а — дальняя. Отсюда можно составить простую систему уравнений:
И полученная матрица проекции:
Наконец, загрузим наши матрицы в шейдер:
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: и , и не на границах треугольника влияние каждого из них на 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
GCU
Раньше похожий эффект делали с помощью прорисовки линиями поверх треугольников с помощью glPolygonOffset без дополнительного буфера и постобработки. Интересно было бы сравнить.
asurkis Автор
Я вдохновлялся двумерными рисунками «от руки», и часть идеи была именно в том, чтобы вес линий менялся в зависимости от выпуклости объекта на сцене, и можно заметить, что тупые углы (в которых переход небольшой) отображаются светло-голубыми линиями, а сами границы модели (где как раз наибольшая разность «высот») — почти черными.
Можно такое сделать прорисовкой поверх треугольников? Было бы интересно посмотреть на результат.
GCU
Я думаю что поскольку ребро получается в результате пересечения двух плоскостей, то две нормали к поверхностям как дополнительные атрибуты вершины позволят вычислить нужный цвет линии. Но это уже вершинный шейдер — раньше его не было.