Введение
Высокодетализированные модели, состоящие из большого количества полигонов, требуют больших вычислительных мощностей от графического процессора, рассчитывающего трансформацию геометрии и освещение фрагментов. Для решений проблемы с низкой детализацией моделей, обладающих малым числом полигонов, разработаны методы, направленные на симуляцию неровностей на плоской поверхности без больших вычислительных затрат и изменения геометрии.
В проекте используется техника отложенного рендера освещения (deffered shading), где нормали и сведения о материалах запекаются в текстуры кадра на этапе G-буфера. Методы рельефного текстурирования в данной заметке будут использоваться именно на этапе G-буфера, а расчет освещения останется прежним.
В заметке используется диффузная текстура, черно-белая текстура для bump/displacement mapping’a и карта нормалей, расположенные в репозитории resources и представленные на рисунках 1-3.
Для демонстрации примеров создана сцена, состоящая из горизонтальной поверхности и сферы, которая отрендерена в программе 3ds Max с рендерером Arnold. Первый результат рендера — без применения техник рельефного текстурирования, изображен на рисунке 4.
В заметке рассматривается теория по следующим методам:
А так же:
Bump mapping
Bump mapping использует черно-белую карту высот, которая используется для описания неровностей поверхности. Данные из текстуры используются для виртуального смещения пикселя путем изменения положения нормали, благодаря чему получаются по-разному освещенные участки. По сути происходит мнимый сдвиг фрагмента и освещение рассчитывается как если бы фрагмент находился в этом месте. Данный эффект может быть использован для моделирования микрорельефа и несложных бугристых поверхностей.
Пример применения бампмаппинга представлен на рисунке 5.
Метод бампмаппинга теряет свою магию под близким к перпендикулярному углу обзора, так как на самом деле геометрия остается плоской. Посмотрев на рисунок 5 можно сделать вывод, что объемность текста на сфере при приближении к краю сферы теряется.
Помимо этого метод bump маппинга не является оптимальным для использования в процессе вычисления каждого кадра. Более грамотным будет его использование для генерации текстур нормалей при загрузке модели, либо при предварительной конвертации в карты нормалей.
Normal mapping
Normal mapping (текстура нормалей) продолжает идею, заложенную бампмаппингом, но использует RGB карту нормалей заместо карты высот. На основе этой карты нормалей, заменяющей нормали вершины, происходит вычисление освещения. Благодаря использованию трех компонент цвета для задания направления вектора нормали получается достичь большей точности, нежели при использовании бампмаппинга, который использует один канал цвета.
Карты нормалей бывают двух видов — относительно пространства в котором они задаются:
- касательное пространство — локальная система координат треугольника;
- пространство модели — мировая система координат, получаемая после трансформации вершины в мировые координаты.
Примечание: касательная плоскость рассматривается далее.
Для создания карт нормалей зачастую используются две модели — высокополигональная и низкополигональная, к которой будет применяться карта нормалей. Помимо этого можно создать карту нормалей из карты высот с помощью инструмента NVIDIA Texture Tools. Для текстуры нормалей из заметки использовался второй способ. Пример использования такой текстуры представлен на рисунке 6.
Можно заметить, что применение карт нормалей дает более реалистичный результат по сравнению с черно-белой картой высот при бампмаппинге. Но данный результат зависит от реализации метода конвертации, которые отличаются у приложения NVidia и выбранного рендерера 3ds Max.
Parallax mapping
Parallax mapping (offset mapping) — метод схожий с displacement, но не требующий большого числа вершин. Идея метода заключается в сдвиге текстурных координат фрагмента для создания иллюзии сдвига высоты. Все вычисления эффекта параллакса производятся на фрагментном шейдере.
В методе может быть использована как текстура высот, так и текстура глубин (инвертированная карта высот = 1.0 — значение высоты). В заметке используется карта высот и в процессе вычислений значения инвертируются.
Для вычислений величины сдвига используется вектор Camvertex, направленный в сторону камеры от фрагмента в точке, определенной векторами положения в пространстве worldPos и текстурными координатами texCoord. Так как все вычисления производятся на фрагментном шейдере, следовательно проверки глубины уже произведены и во избежание артефактов, сдвиг будет направлен от камеры, а не к ней, как это было бы при использовании метода displacement mapping. Вектор Camvertex следует привести в касательное пространство рассматриваемого фрагмента с помощью TBN матрицы и нормировать, назовем данный вектор CamvertexTBN.
Примечание: касательная плоскость рассматривается далее.
Эффект параллакса может иметь два направления в зависимости от знака delta_texCoord. Автор предпочитает использовать инвертированные — вектор delta_texCoord направлен в обратную сторону по отношению к CamvertexTBN.
Для вычисления delta_texCoord потребуется умножить вектор CamvertexTBN на значение из текстуры высот — H(texCoord), и отрицательный коэффициент масштабирования эффекта параллакса (ParallaxScale). Итоговое выражение для вычисления новых текстурных координат имеет следующий вид:
new_texCoord = texCoord + delta_texCoord,
где delta_texCoord = — ParallaxScale * H(texCoord) * normalize(TBN * Camvertex),
тогда итоговое выражение равно:
new_texCoord = texCoord — ParallaxScale * H(texCoord) * normalize(TBN * Camvertex)
На рисунке 7 изображены векторы, используемые при вычислении метода параллакса. На верхней части рисунка изображена плоскость с видом сбоку, когда ось X направлена от плоскости монитора, а на нижней — касательное пространство с текстурой высот (ось Z направлена от плоскости монитора). Рисунок рекомендуется открыть в новой вкладке для мобильных устройств.
Примечание: для удобства проекции текстурных координат текстура, используемая на рисунке 7 растянута по оси X.
Коэффициент ParallaxScale позволяет масштабировать эффект для конкретной сцены, позволяя делать его как более, так и менее выраженным.
Displacement mapping
Displacement mapping (текстура вытеснения или смещения) изменяет геометрию поверхности по значениям из карты высот на этапе вершинного шейдера. Его преимущество заключается в том, что освещение считается без дополнительных преобразований на фрагментном шейдере. Данный метод не может быть применен к низкополигональным моделям, так как требует детализированную сетку, которая будет изменена текстурой.
Для повышения детализации сетки могут быть использованы шейдеры тесселяции, которые генерируют дополнительную геометрию, основываясь на заданных параметрах.
Пример применения текстуры смещений изображен на рисунке 8.
По рисунку 8 можно заметить, что радиус сферы и высота плоскости не изменились, при применении текстуры черные участки остаются на оригинальной плоскости, а белые поднимаются на заданное значение displacement (1.0). Результат имеет грубоватые грани, которые можно дополнительно смягчить с помощью текстуры нормалей.
На рисунке 9 представлено совмещение карт смещения и нормалей для получения более мягкого перехода.
Касательное пространство
Касательное пространство — система координат, локальная для плоскости полигона. Для преобразования векторов используется матрица перехода TBN, состоящая из трех векторов, трансформированных в мировое пространство: касательный (Tangent), бикасательный (Bitangent), нормали (Normal). Пример расположения векторов изображен на рисунке 10.
Для нахождения векторов Tangent и Bitangent в локальном для фрагмента пространстве могут быть использованы стороны треугольника: E1(TexCoord1, TexCoord2) и E2(TexCoord1, TexCoord3)
Эти стороны можно представить следующим выражением с помощью текстурных координат:
E1 = (u2-u1)*Tangent + (v2-v1)*Bitangent
E2 = (u3-u1)*Tangent + (v3-v1)*Bitangent
Данные выражения можно преобразовать в произведении матриц:
E1.x | E1.y | E1.z |
E2.x | E2.y | E2.z |
=
u2-u1 | v2-v1 |
u3-u1 | v3-v1 |
*
T.x | T.y | T.z |
B.x | B.y | B.z |
Домножив на обратную матрицу разности текстурных координат получим следующее выражение:
T.x | T.y | T.z |
B.x | B.y | B.z |
=
u2-u1 | v2-v1 |
u3-u1 | v3-v1 |
-1
*
E1.x | E1.y | E1.z |
E2.x | E2.y | E2.z |
Обратная матрица разностей текстурных координат может быть представлена как:
u2-u1 | v2-v1 |
u3-u1 | v3-v1 |
-1
=
((u2-u1)(v3-v1)-(u3-u1)(v2-v1))-1
v3-v1 | v1-v2 |
u1-u3 | u2-u1 |
Стороны треугольника можно представить следующим выражением:
E1.x | E1.y | E1.z |
E2.x | E2.y | E2.z |
=
x2-x1 | y2-y1 | z2-z1 |
x3-x1 | y3-y1 | z3-z1 |
Для удобства вычислений определим:
f = ((u2-u1)(v3-v1)-(u3-u1)(v2-v1))-1
Итоговые формулы для вычисления будут следующими:
Tangent = f * ((v3-v1)(Vertex2-Vertex1)-(v2-v1)(Vertex3-Vertex1))
Bitangent = f * ((u3-u1)(Vertex2-Vertex1)-(u2-u1)(Vertex3-Vertex1))
Для хранения данных о касательных и бикасательных векторах будут использоваться вершинные буферы, которые необходимо добавить в файле include/Model.h:
class Model : public Node
{
public:
...
void load_tangent(glm::vec3* tangent, GLuint count); // Загрузка касательных векторов в буфер
void load_bitangent(glm::vec3* bitangent, GLuint count); // Загрузка бикасательных векторов в буфер
...
private:
...
BO tangent_vbo, bitangent_vbo; // буферы с касательными и бикасательными векторами
...
};
Дополним конструкторы для взаимодействия с новыми буферами в файле src/Model.cpp:
// Конструктор по умолчанию
Model::Model(Node *parent) : Node(parent), verteces_count(0), first_index_byteOffset(0), indices_count(0),
vertex_vbo(VERTEX), index_vbo(ELEMENT), normals_vbo(VERTEX), texCoords_vbo(VERTEX),
tangent_vbo(VERTEX), bitangent_vbo(VERTEX)
{
}
// Конструктор копирования
Model::Model(const Model& copy) : Node(copy),
vao(copy.vao),
verteces_count(copy.verteces_count), first_index_byteOffset(copy.first_index_byteOffset), indices_count(copy.indices_count),
vertex_vbo(copy.vertex_vbo), index_vbo(copy.index_vbo), normals_vbo(copy.normals_vbo), texCoords_vbo(copy.texCoords_vbo),
tangent_vbo(copy.tangent_vbo), bitangent_vbo(copy.bitangent_vbo),
texture_diffuse(copy.texture_diffuse), texture_ambient(copy.texture_ambient), texture_specular(copy.texture_specular),
material(copy.material)
{
}
// Оператор присваивания
Model& Model::operator=(const Model& other)
{
Node::operator=(other); // Явный вызов родительского оператора копирования
vao = other.vao;
verteces_count = other.verteces_count;
first_index_byteOffset = other.first_index_byteOffset;
indices_count = other.indices_count;
vertex_vbo = other.vertex_vbo;
index_vbo = other.index_vbo;
texCoords_vbo = other.texCoords_vbo;
tangent_vbo = other.tangent_vbo;
bitangent_vbo = other.bitangent_vbo;
texture_diffuse = other.texture_diffuse;
texture_ambient = other.texture_ambient;
texture_specular = other.texture_specular;
material = other.material;
return *this;
}
А так же добавим две функции для конфигурации атрибутов и два метода для загрузки данных в буферы:
// Функция для конфигурации атрибута вершинного буфера
void tangent_attrib_config()
{
// Определим спецификацию атрибута
glVertexAttribPointer( 3 // индекс атрибута, должен совпадать с Layout шейдера
, 3 // количество компонент одного элемента
, GL_FLOAT // тип
, GL_FALSE // необходимость нормировать значения
, 0 // шаг
, (void *)0 // отступ с начала массива
);
// Включаем необходимый атрибут у выбранного VAO
glEnableVertexAttribArray(3);
}
// Функция для конфигурации атрибута вершинного буфера
void bitangent_attrib_config()
{
// Определим спецификацию атрибута
glVertexAttribPointer( 4 // индекс атрибута, должен совпадать с Layout шейдера
, 3 // количество компонент одного элемента
, GL_FLOAT // тип
, GL_FALSE // необходимость нормировать значения
, 0 // шаг
, (void *)0 // отступ с начала массива
);
// Включаем необходимый атрибут у выбранного VAO
glEnableVertexAttribArray(4);
}
// Загрузка касательных векторов в буфер
void Model::load_tangent(glm::vec3* tangent, GLuint count)
{
// Подключаем VAO
vao.use();
tangent_vbo.use();
// Загрузка вершин в память буфера
tangent_vbo.load(tangent, sizeof(glm::vec3)*count);
tangent_attrib_config();
}
// Загрузка бикасательных векторов в буфер
void Model::load_bitangent(glm::vec3* bitangent, GLuint count)
{
// Подключаем VAO
vao.use();
bitangent_vbo.use();
// Загрузка вершин в память буфера
bitangent_vbo.load(bitangent, sizeof(glm::vec3)*count);
bitangent_attrib_config();
}
Для вычислений понадобится функция calc_tb, содержащая цикл по индексам, который обрабатывает тройки индексов:
// Расчет касательных и бикасательных векторов
void calc_tb(const GLuint* indices, const int indices_count, const glm::vec3* verteces, const glm::vec2* texCords, glm::vec3* tangent, glm::vec3* bitangent)
{
glm::vec2 dTex1, dTex2; // Разница по текстурным координатам
glm::vec3 dPos1, dPos2; // Разница по координатам вершин
float f; // Разность произведений
glm::vec3 tmp; // Для вычислений вектора
for (int i = 0; i < indices_count; i+=3)
{
// Разности векторов
dTex1 = texCords[indices[i+1]] - texCords[indices[i]];
dTex2 = texCords[indices[i+2]] - texCords[indices[i]];
dPos1 = verteces[indices[i+1]] - verteces[indices[i]];
dPos2 = verteces[indices[i+2]] - verteces[indices[i]];
f = dTex1.x * dTex2.y - dTex2.x * dTex1.y;
// Покомпонентное вычисление касательного вектора
tmp.x = (dTex2.y * dPos1.x - dTex1.y * dPos2.x) / f;
tmp.y = (dTex2.y * dPos1.y - dTex1.y * dPos2.y) / f;
tmp.z = (dTex2.y * dPos1.z - dTex1.y * dPos2.z) / f;
// Нормируем значение
tmp = glm::normalize(tmp);
// Добавим вектор в контейнер
tangent[indices[i ]] = tmp; // Для каждого индекса полигона
tangent[indices[i+1]] = tmp; // значение вектора
tangent[indices[i+2]] = tmp; // одинаковое
// Покомпонентное вычисление бикасательного вектора
tmp.x = (-dTex2.x * dPos1.x + dTex1.x * dPos2.x) / f;
tmp.y = (-dTex2.x * dPos1.y + dTex1.x * dPos2.y) / f;
tmp.z = (-dTex2.x * dPos1.z + dTex1.x * dPos2.z) / f;
// Нормируем значение
tmp = glm::normalize(tmp);
// Добавим вектор в контейнер
bitangent[indices[i ]] = tmp; // Для каждого индекса полигона
bitangent[indices[i+1]] = tmp; // значение вектора
bitangent[indices[i+2]] = tmp; // одинаковое
}
}
Нужно добавить объявление данной функции в заголовочный файл.
В функции loadOBJtoGroupted изменим размер массивов, добавим вызов данной функции и загрузку сформированных массивов в буфер:
...
// Изменим размер массивов
tangent.resize(verteces.size());
bitangent.resize(verteces.size());
// Расчет касательных и бикасательных векторов
calc_tb(indices.data(), indices.size(), verteces.data(), texCords.data(), tangent.data(), bitangent.data());
// Загрузка в буферы
model.load_verteces (&verteces[0], verteces.size());
model.load_normals (&normals[0], normals.size());
model.load_texCoords(&texCords[0], texCords.size());
model.load_tangent(&tangent[0], tangent.size());
model.load_bitangent(&bitangent[0], bitangent.size());
// Загрузка индексного буфера
model.load_indices (&indices[0], indices.size());
...
Для получения матрицы TBN достаточно создать матрицу из трех векторов: tangent, bitangent, normal, предварительно приведя их в пространство модели. Данная матрица используется для перехода из касательного пространства в мировые координаты. Пример создания такой матрицы:
mat3 TBN = mat3(mat3(model)*T, mat3(model)*B, mat3(model)*N);
Для перехода из мировых координат в касательное пространство модели достаточно просто транспонировать полученную ранее матрицу. Пример образования матрицы для обратного перехода к касательному пространству:
mat3 tTBN = transpose(TBN); // transpose(mat3(T, B, N))
Текущая версия доступна на теге v0.1 в репозитории 16.
Реализация методов
В данном разделе рассматриваются способы реализации методов рельефного текстурирования за исключением бампмаппинга, который по своей сути является преобразованием карт высот к картам нормалей, но сначала требуется дополнить текст программы.
Перед работой добавим соответствующие текстуры к модели в файле include/Model.h:
class Model
{
...
private:
...
Texture texture_heights; // Текстура высот
Texture texture_normals; // Текстура нормалей
};
А также дополним конструктор копирования, оператор присваивания и функцию привязки текстуры в файле src/Model.cpp:
// Конструктор копирования
Model::Model(const Model& copy) :
vao(copy.vao),
verteces_count(copy.verteces_count), first_index(copy.first_index), indices_count(copy.indices_count),
vertex_vbo(copy.vertex_vbo), index_vbo(copy.index_vbo), normals_vbo(copy.normals_vbo), texCoords_vbo(copy.texCoords_vbo),
tangent_vbo(copy.tangent_vbo), bitangent_vbo(copy.bitangent_vbo),
texture_diffuse(copy.texture_diffuse), texture_ambient(copy.texture_ambient), texture_specular(copy.texture_specular),
texture_heights(copy.texture_heights), texture_normals(copy.texture_normals),
position(copy.position), rotation(copy.rotation), scale(copy.scale), material(copy.material)
...
// Оператор присваивания
Model& Model::operator=(const Model& other)
{
...
texture_diffuse = other.texture_diffuse;
texture_ambient = other.texture_ambient;
texture_specular = other.texture_specular;
texture_heights = other.texture_heights;
texture_normals = other.texture_normals;
material = other.material;
return *this;
}
...
// Привязка текстуры к модели
void Model::set_texture(Texture& texture)
{
GLuint type = texture.getType();
switch(type)
{
case TEX_DIFFUSE:
texture_diffuse = texture;
break;
case TEX_AMBIENT:
texture_ambient = texture;
break;
case TEX_SPECULAR:
texture_specular = texture;
break;
case TEX_HEIGHTS:
texture_heights = texture;
break;
case TEX_NORMAL:
texture_normals = texture;
break;
};
}
В файле include/Texture.h добавим новые виды текстур к перечислению:
enum TexType {
TEX_DIFFUSE,
TEX_AMBIENT,
TEX_SPECULAR,
TEX_HEIGHTS,
TEX_NORMAL,
TEX_AVAILABLE_COUNT
};
Добавим привязку при создании шейдера в файле src/main.cpp:
// Шейдер для G-буфера
ShaderProgram gShader;
// Загрузка и компиляция шейдеров
gShader.load(GL_VERTEX_SHADER, "shaders/gshader.vert");
gShader.load(GL_FRAGMENT_SHADER, "shaders/gshader.frag");
gShader.link();
// Установим значения текстур
const char* textures_base_shader_names[] = {"tex_diffuse", "tex_ambient", "tex_specular", "tex_heights", "tex_normal"};
gShader.bindTextures(textures_base_shader_names, sizeof(textures_base_shader_names)/sizeof(const char*));
В файле src/Texture.cpp конструкторы, загружающие текстуру с диска, необходимо дополнить уточнением: если загружаемая текстура является картой нормалей или высот, то требуется использовать обычный формат заместо sRGB и sRGB_APLHA. Дополнительно добавим возможность загрузки одно- и двух-канальных текстур:
Texture::Texture(GLuint t, const std::string& filename)
{
...
// Если изображение успешно считано
if (image)
{
// Выбор формата с учетом типа текстуры (нормали не sRGB) и числа каналов
GLuint internalformat = GL_RGB, format = GL_RGB;
switch (channels)
{
case 1:
internalformat = format = GL_RED;
break;
case 2:
internalformat = format = GL_RG;
break;
case 3:
format = GL_RGB;
if (type == TEX_NORMAL || type == TEX_HEIGHTS)
internalformat = GL_RGB;
else
internalformat = GL_SRGB;
break;
case 4:
format = GL_RGBA;
if (type == TEX_NORMAL || type == TEX_HEIGHTS)
internalformat = GL_RGBA;
else
internalformat = GL_SRGB_ALPHA;
break;
}
// Загрузка данных с учетом формата
glTexImage2D(GL_TEXTURE_2D, 0, internalformat, width, height, 0, format, GL_UNSIGNED_BYTE, image);
...
TextureCube::TextureCube(GLuint t, const std::string (&filename)[6])
{
...
for (int i = 0; i < 6; i++)
{
image = stbi_load(filename[i].c_str(), &width, &height, &channels, STBI_default); // Загрузка в оперативную память изображения
// Если изображение успешно считано
if (image)
{
// Выбор формата с учетом типа текстуры (нормали не sRGB) и числа каналов
GLuint internalformat = GL_RGB, format = GL_RGB;
switch (channels)
{
case 1:
internalformat = format = GL_RED;
break;
case 2:
internalformat = format = GL_RG;
break;
case 3:
format = GL_RGB;
if (type == TEX_NORMAL || type == TEX_HEIGHTS)
internalformat = GL_RGB;
else
internalformat = GL_SRGB;
break;
case 4:
format = GL_RGBA;
if (type == TEX_NORMAL || type == TEX_HEIGHTS)
internalformat = GL_RGBA;
else
internalformat = GL_SRGB_ALPHA;
break;
}
// Загрузка данных с учетом формата
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, internalformat, width, height, 0, format, GL_UNSIGNED_BYTE, image);
...
Добавим активацию текстур в методе Model::render в файле src/Model.cpp:
// Подключаем текстуры
texture_diffuse.use();
texture_ambient.use();
texture_specular.use();
texture_heights.use();
texture_normals.use();
Дополним загрузчик моделей — функцию loadOBJtoScene в файле src/Scene.cpp:
// Создаем копии модели, которые будут рендериться в заданном диапазоне
// И присваиваем текстуры копиям на основании материала
for (int i = 0; i < materials_range.size()-1; i++)
{
result.models.push_back(model); // Создание копии с общим VAO
auto s = --result.models.end();
s->set_index_range(materials_range[i]*sizeof(GLuint), materials_range[i+1]-materials_range[i]);
// Текстуры
Texture diffuse(TEX_DIFFUSE, texture_directory + materials[materials_ids[i]].diffuse_texname);
s->set_texture(diffuse);
Texture ambient(TEX_AMBIENT, texture_directory + materials[materials_ids[i]].ambient_texname);
s->set_texture(ambient);
Texture specular(TEX_SPECULAR, texture_directory + materials[materials_ids[i]].specular_texname);
s->set_texture(specular);
Texture normal(TEX_NORMAL, texture_directory + materials[materials_ids[i]].normal_texname);
s->set_texture(normal);
Texture heights(TEX_HEIGHTS, texture_directory + materials[materials_ids[i]].bump_texname);
s->set_texture(heights);
// Материал
s->material.ka = pow(glm::vec3(materials[materials_ids[i]].ambient[0], materials[materials_ids[i]].ambient[1], materials[materials_ids[i]].ambient[2]), glm::vec3(1/inv_gamma));
s->material.kd = pow(glm::vec3(materials[materials_ids[i]].diffuse[0], materials[materials_ids[i]].diffuse[1], materials[materials_ids[i]].diffuse[2]), glm::vec3(1/inv_gamma));
s->material.ks = glm::vec3(materials[materials_ids[i]].specular[0], materials[materials_ids[i]].specular[1], materials[materials_ids[i]].specular[2]);
s->material.p = (materials[materials_ids[i]].shininess > 0.0f) ? 1000.0f / materials[materials_ids[i]].shininess : 1000.0f;
}
В файле src/main.cpp добавим для плоскости: текстуры, текстурные координаты и касательные/бикасательные векторы.
// Текстуры для прямоугольника
Texture rectangle_diffuse(TEX_DIFFUSE, "../resources/textures/rekovalev_diffusemap.png");
rectangle.set_texture(rectangle_diffuse);
Texture rectangle_normal(TEX_NORMAL, "../resources/textures/rekovalev_normalmap.png");
rectangle.set_texture(rectangle_normal);
Texture rectangle_heights(TEX_HEIGHTS, "../resources/textures/rekovalev_bumpmap.png");
rectangle.set_texture(rectangle_heights);
// Текстурные координаты
glm::vec2 rectangle_texCoord[] = { { 1.0f, 0.0f }
, { 1.0f, 1.0f }
, { 0.0f, 1.0f }
, { 0.0f, 0.0f }
};
rectangle.load_texCoords(rectangle_texCoord, sizeof(rectangle_texCoord)/sizeof(glm::vec2));
// Касательные и бикасательные векторы
glm::vec3 rectangle_tangent[4], rectangle_bitangent[4];
calc_tb(rectangle_indices, 6, rectangle_verticies, rectangle_texCoord, rectangle_tangent, rectangle_bitangent);
rectangle.load_tangent(rectangle_tangent, 4);
rectangle.load_bitangent(rectangle_bitangent, 4);
Для работы с новыми буферами дополним вершинный шейдер shaders/gshader.vert, который передаст их значения на фрагментный:
layout(location = 0) in vec3 pos;
layout(location = 1) in vec2 inTexCoord;
layout(location = 2) in vec3 normals;
layout(location = 3) in vec3 tangent;
layout(location = 4) in vec3 bitangent;
...
out vec3 vertex; // Позиция вершины в пространстве
out vec3 N; // Нормаль трансформированноая
out vec2 texCoord; // Текстурные координаты
out vec3 T; // Касательный вектор
out vec3 B; // Бикасательный вектор
void main()
{
...
T = normalize(mat3(model) * tangent);
B = normalize(mat3(model) * bitangent);
...
}
А так же дополним фрагментный shaders/gshaders.frag:
in vec3 vertex; // Позиция вершины в пространстве
in vec3 N; // Нормаль трансформированная
in vec2 texCoord; // Текстурные координаты
in vec3 T; // Касательный вектор
in vec3 B; // Бикасательный вектор
uniform sampler2D tex_diffuse;
uniform sampler2D tex_ambient;
uniform sampler2D tex_specular;
uniform sampler2D tex_heights;
uniform sampler2D tex_normal;
void main()
{
// Сформируем TBN матрицу
mat3 TBN = mat3(T, B, N);
...
Текущая версия доступна на теге v0.2 в репозитории 16.
На данный момент приложение выводит результат, представленный на рисунке 11.
Теперь можно приступить к реализации методов текстурирования.
Normal mapping
Так как некоторые модели могут не использовать карту нормалей необходимо дополнить структуру материала полем Material::normalmapped в файле include/Model.h:
// Материал модели
struct Material
{
alignas(16) glm::vec3 ka; // коэф. фонового отражения (цвет фонового освещения)
alignas(16) glm::vec3 kd; // коэф. диффузного отражения (цвет объекта)
alignas(16) glm::vec3 ks; // коэф. зеркального блика
float p; // показатель глянцевости
int normalmapped; // Использование карт нормалей
// Значения по умолчанию
Material() : ka(0.2f), kd(0.2f), ks(0.2f), p(1), normalmapped(false) { };
};
Важное замечание: тип нового поля структуры является int во избежание проблем с выравниванием и представлением bool в памяти видеокарты, который занимает 4 байта (32 бита). В шейдерах можно использовать bool для простоты работы.
Включим использование карт нормалей в файле src/main.cpp для прямоугольника:
rectangle.material.normalmapped = true;
В фрагментных шейдерах shaders/gshader.frag и shaders/bulb.frag добавим логическое (bool) поле к uniform-буферу материала:
layout(std140, binding = 1) uniform Material
{
vec3 ka;
vec3 kd;
vec3 ks;
float p;
bool normalmapped;
};
В фрагментном шейдере shaders/gshader.frag изменим сохранение нормали в G-буфере для случая, если включено использование карт нормалей:
// Сохранение нормали в G-буфере
gNormal = N;
// Если используется карта нормалей
if (normalmapped)
{
// Получим значение из карты нормалей и приведем их к диапазону [-1;1]
gNormal = texture(tex_normal, texCoord).rgb * 2 - 1.0f;
gNormal = TBN * gNormal; // Касательное пространство
}
Результат работы приложения с картами нормалей представлен на рисунке 12.
Исходя из результатов рисунка 11 можно сделать вывод, что метод карт нормалей создает эффект объема, но он остается весьма плоским из-за отсутствия физического сдвига фрагмента.
Текущая версия доступна на теге v0.3 в репозитории 16.
Parallax mapping
Так как некоторые модели могут не использовать эффект параллакса необходимо дополнить структуру материала полем Material::parallaxmapped в файле include/Model.h:
// Материал модели
struct Material
{
alignas(16) glm::vec3 ka; // коэф. фонового отражения (цвет фонового освещения)
alignas(16) glm::vec3 kd; // коэф. диффузного отражения (цвет объекта)
alignas(16) glm::vec3 ks; // коэф. зеркального блика
float p; // показатель глянцевости
int normalmapped; // Использование карт нормалей
int parallaxmapped; // Использование параллакса
// Значения по умолчанию
Material() : ka(0.2f), kd(0.2f), ks(0.2f), p(1), normalmapped(false), parallaxmapped(false) { };
};
Включим использование карт нормалей в файле src/main.cpp для прямоугольника:
rectangle.material.parallaxmapped = true;
В фрагментных шейдерах shaders/gshader.frag и shaders/bulb.frag добавим логическое (bool) поле к uniform-буферу материала:
layout(std140, binding = 1) uniform Material
{
vec3 ka;
vec3 kd;
vec3 ks;
float p;
bool normalmapped;
bool parallaxmapped;
};
В фрагментном шейдере shaders/gshader.frag дополнительно потребуется uniform-переменная для масштабирования эффекта со значением по умолчанию:
uniform float parallax_heightScale = 0.03;
Для определения сдвига требуется вычислить вектор view (вектор между поверхностью и камерой) на вершинном шейдере shaders/gshader.vert, который позднее будет использован в фрагментном и трансформирован в касательное пространство:
...
out vec3 view; // Вектор от поверхности к камере
void main()
{
...
view = camera.position - vertex;
...
}
Примечание: данный вектор можно вычислить как на фрагментном шейдере, так и провести трансформацию в касательное пространство на вершинном шейдере.
Теперь можно вернуться к вычислениям в фрагментном шейдере shaders/gshader.frag. Приведем полученный вектор в касательное пространство:
in vec3 view; // Вектор от поверхности к камере
...
void main()
{
// Сформируем TBN матрицу
mat3 TBN = mat3(T, B, N);
// Перевод вектора в касательное пространство
vec3 viewTBN = normalize(transpose(TBN) * view);
...
Так как метод параллакса основан на изменении текстурных координат, то требуется добавить переменную new_texCoord, которая будет использоваться для всех последующих вызовов функции texture:
void main()
{
...
// Измененные текстурные координаты
vec2 new_texCoord = texCoord;
...
gNormal = texture(tex_normal, new_texCoord).rgb * 2 - 1.0f;
...
gDiffuseP.rgb = texture(tex_diffuse, new_texCoord).rgb * kd;
...
gAmbientSpecular.rgb = texture(tex_ambient, new_texCoord).rgb * ka;
gAmbientSpecular.a = texture(tex_specular, new_texCoord).r * ks.r;
Вычисление сдвинутых текстурных координат производится перед первым их использованием (перед использованием карт нормалей). Самая простая версия основана на простом сдвиге координат на основании вектора view в касательном пространстве:
if (parallaxmapped)
{
float height = 1.0 - texture(tex_heights, texCoord).r;
new_texCoord -= viewTBN.xy * (height * parallax_heightScale);
if(new_texCoord.x > 1.0 || new_texCoord.y > 1.0 || new_texCoord.x < 0.0 || new_texCoord.y < 0.0)
discard;
}
Важное замечание: измененные текстурные координаты требуется проверить на выход за границы диапазона [0;1] во избежание возникновения артефактов.
Пример работы простой реализации эффекта параллакса с плоскостью расположенной перед камерой изображен на рисунке 13.
По рисунку 13 можно сделать вывод, что простой эффект параллакса хорошо деформирует внешний вид плоскости (на примере буквы S эффект наиболее наглядно заметен), но лишь на малых значениях parallax_heightScale (0.03) и для перпендикулярных камере плоскостей. Изменения методом параллакса не являются специфичными для расчета освещения, и для падающего луча поверхность остается плоской (левая часть рисунка 13). Данную ситуацию можно решить включив карту нормалей (правая часть рисунка 13).
На рисунке 14 изображены искажения для плоскости, параллельной вектору направления камеры.
Для возможности использовать большие значения parallax_heightScale необходимо использовать более продвинутый подход. Одним из рассмотренных ранее подходов является метод параллакса с преграждениями (parallax occlusion). Идея данного метода основана на интерполяции значений между срезами глубин слоев.
Для реализации данной модификации простого параллакса необходимо задать число слоев, которое будет влиять на максимальную высоту эффекта без появления расслоения, но при этом создавать дополнительную вычислительную нагрузку. Исходя из количества слоев вычислим глубину одного слоя и величину сдвига:
// Число слоев
float layersCount = 32;
// Вычислим размер каждого слоя
float layerDepth = 1.0 / layersCount;
// Глубина текущего слоя
float currentLayerDepth = 0.0;
// Величина сдвига между слоями
vec2 deltaTexCoords = (parallax_heightScale * viewTBN.xy / viewTBN.z) / layersCount;
Важное замечание: вектор viewTBN.xy делится на составляющую по оси Z для нейтрализации искажений в следствии нормирования вектора viewTBN. Можно просто не нормировать вектор. Пример такого искажения изображен на рисунке 16.
А так же добавим дополнительные переменные для вычислений:
vec2 currentTexCoords = texCoord;
float currentDepthMapValue = 1.0 - texture(tex_heights, currentTexCoords).r;
Требуется найти граничный слой, для которого глубина текущего слоя меньше значения глубины из текстуры:
while(currentLayerDepth < currentDepthMapValue)
{
// Сдвигаем координаты
currentTexCoords -= deltaTexCoords;
// Обновляем значение глубины из текстуры
currentDepthMapValue = 1.0 - texture(tex_heights, currentTexCoords).r;
// Сдвигаем глубину на следующий слой
currentLayerDepth += layerDepth;
}
После определения порога вычислим координаты предыдущего слоя и возьмем значения глубины для интерполяции:
// Получим значение текстурных координат с предыдущего шага
vec2 prevTexCoords = currentTexCoords + deltaTexCoords;
// Значения глубины до и после пересечения
float afterDepth = currentDepthMapValue - currentLayerDepth;
float beforeDepth = 1.0 - texture(tex_heights, prevTexCoords).r - currentLayerDepth + layerDepth;
Для интерполяции требуется вычислить коэффициент (вес) и применить его при определении :
float weight = afterDepth / (afterDepth - beforeDepth);
new_texCoord = prevTexCoords * weight + currentTexCoords * (1.0 - weight);
Результат работы метода параллакса с преграждениями (parallax occlusion) с высотой эффекта (parallax_heightScale) 0.1 представлен на рисунке 17.
Текущая версия доступна на теге v0.4 в репозитории 16.
Displacement mapping
Для реализации смещения требуется большое число вершин, которые будут сдвинуты. Один из вариантов применения текстур смещения: использование тесселяции — технология для увеличения числа полигонов модели, использующая кривые Безье. Тема тесселяции комплексная и требует отдельную заметку, в которой обязательно рассмотрен метод смещения при использовании тесселяции. Здесь будет использована высокополигональная модель, к которой будет переменено смещение координат по значениям из текстуры высот.
Так как некоторые модели могут не использовать эффект параллакса необходимо дополнить структуру материала полем Material::displacementmapped в файле include/Model.h:
// Материал модели
struct Material
{
alignas(16) glm::vec3 ka; // коэф. фонового отражения (цвет фонового освещения)
alignas(16) glm::vec3 kd; // коэф. диффузного отражения (цвет объекта)
alignas(16) glm::vec3 ks; // коэф. зеркального блика
float p; // показатель глянцевости
int normalmapped; // Использование карт нормалей
int parallaxmapped; // Использование параллакса
int displacementmapped; // Использование карт высот для сдвига вершин
// Значения по умолчанию
Material() : ka(0.2f), kd(0.2f), ks(0.2f), p(1), normalmapped(false), parallaxmapped(false), displacementmapped(false) { };
};
Заменим модель низкополигонального прямоугольника, на готовую .obj модель, состоящую из 80000 фрагментов. Данную модель и файл материалов можно скачать из репозитория resources.
// Модель прямоугольника
Scene rectangle = loadOBJtoScene("../resources/models/plane80000.obj", "../resources/models/", "../resources/textures/");
// Зададим горизонтальное положение перед камерой
rectangle.root.e_position().y = -1;
rectangle.root.e_position().z = 2;
rectangle.root.e_rotation() = glm::quat(0.707f, 0.707f, 0.0f, 0.0f);
rectangle.root.e_scale() = glm::vec3(4);
// Текстуры для прямоугольника
Texture rectangle_diffuse(TEX_DIFFUSE, "../resources/textures/rekovalev_diffusemap.png");
rectangle.models.begin()->set_texture(rectangle_diffuse);
Texture rectangle_normal(TEX_NORMAL, "../resources/textures/rekovalev_normalmap.png");
rectangle.models.begin()->set_texture(rectangle_normal);
Texture rectangle_heights(TEX_HEIGHTS, "../resources/textures/rekovalev_bumpmap.png");
rectangle.models.begin()->set_texture(rectangle_heights);
rectangle.models.begin()->material.displacementmapped = true;
Дополним вершинный шейдер shaders/gshader.vert uniform-переменными:
layout(std140, binding = 1) uniform Material
{
vec3 ka;
vec3 kd;
vec3 ks;
float p;
bool normalmapped;
bool parallaxmapped;
bool displacementmapped;
};
uniform sampler2D tex_heights;
uniform float displacement_heightScale = 0.1;
А также фрагментные шейдеры shaders/gshader.frag и shaders/bulb.frag:
layout(std140, binding = 1) uniform Material
{
vec3 ka;
vec3 kd;
vec3 ks;
float p;
bool normalmapped;
bool parallaxmapped;
bool displacementmapped;
};
Перед вычислением gl_Position дополним вектор P вектором смещения по оси Z, приведенным к мировым координатам из касательного пространства с помощью матрицы TBN:
void main()
{
...
if (displacementmapped)
{
float height = texture(tex_heights, texCoord).r * displacement_heightScale;
P.xyz += mat3(T, B, N) * vec3(0, 0, height);
}
gl_Position = camera.projection * camera.view * P;
}
Результат работы приложения с использованием текстур смещения представлен на рисунке 18.
Случай использования менее детализированной модели со снижением качества результата представлен на рисунке 19.
Текущая версия доступна на теге v0.5 в репозитории 16.
Для дальнейших заметок не требуется столь детализированный прямоугольник — заменим её моделью resources/models/plane2.obj из репозитория resources.
В файле src/main.cpp изменим имя загружаемого файла и отключим текстурное смещение:
// Модель прямоугольника
Scene rectangle = loadOBJtoScene("../resources/models/plane2.obj", "../resources/models/", "../resources/textures/");
...
rectangle.models.begin()->material.displacementmapped = true;
Текущая версия доступна на теге v0.6 в репозитории 16.
Заключение
В данной заметке рассмотрены методы рельефного текстурирования — использования текстур для повышения детализации моделей, такие как: bump, normal, parallax и displacement. Для методов рассмотрена теория и способы реализации на языке шейдеров GLSL.
Проект доступен в публичном репозитории: 16
Библиотеки: dependencies
Ресурсы: resources
2 ответа к “OpenGL 16: рельефное текстурирование”
Приветствую! я решил повторить создание такой сцены из ваших записок. сначала у самого не получилось добиться работы карт нормалей, рельефа и тд. Затем я загрузил и скомпилировал код вашего последнего репозитория (16) и оказалось, что там тоже не работают никакие карты. Работали только карты отражений. Почему у меня может быть такая ошибка?
Хороший вопрос. Обратите внимание, что последний коммит отключает все рельефное текстурирование.
Написал вам на почту для дальнейшего обсуждения