OGL3

Parallax Mapping


Техника текстурирования Parallax Mapping по своему эффекту несколько схожа с Normal Mapping’ом, но основана на другом принципе. Схожесть в том, что, как и Normal Mapping, данная техника значительно увеличивает визуальную сложность и детализацию поверхности с нанесенной текстурой заодно создавая правдоподобную иллюзия наличия на поверхности перепадов высот. Parallax Mapping отлично работает в связке с Normal Mapping для создания весьма достоверных результатов: описываемая техника передает эффект рельефа гораздо лучше Normal Mapping, а Normal Mapping дополняет его для правдоподобной имитации динамического освещения. Parallax Mapping вряд ли можно считать техникой, прямо относящейся к методам имитации освещения, но все же я выбрал этот раздел для его рассмотрения, поскольку метод является логическим развитием идей Normal Mapping. Также отмечу, что для разбора этой статьи требуется хорошее понимание алгоритма работы Normal Mapping, в особенности понятия касательного пространства или tangent space.


Parallax Mapping относится к семейству техник Displacement Mapping или рельефного текстурирования, которые смещают вершины геометрии на основе значений, хранящихся в специальных текстурных картах. Для примера представьте плоскость, составленную из порядка тысячи вершин. Каждую из них можно сместить согласно величине, считанной из текстуры, представляющую собой высоту плоскости в данной точки. Такая текстура, содержащая значения высоты в каждом текселе, называется картой высот. Примером такой карты, полученной на основе геометрических характеристик поверхности кирпичной кладки, может служить следующее изображение:


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


Описанный подход прост и легок в реализации, но требует большой плотности вершин в обрабатываемом объекте, иначе результат смещения будет слишком грубым. И если на каждую плоскую поверхность начать отпускать по тысяче с лишком вершин, то очень скоро мы просто не будем успевать рендерить все, что нам требуется. Может быть найдется алгоритм, позволяющий качественно сымитировать качество наивного алгоритма Displacement Mapping, но при этом не требуя таких затрат на геометрию? Если стоите – сядьте, поскольку на изображении выше на самом деле присутствует всего шесть вершин (два треугольника)! Рельеф кирпичной кладки отлично сымитирован благодаря использованию Parallax Mapping, техники рельефного текстурирования, не требующей множества вершин для правдоподобной передачи рельефа поверхности, а, как и Normal Mapping, использующей оригинальный подход для обмана глаз наблюдателя.

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

Здесь грубая красная линия представляет собой значения из карты высот, отражающие геометрические характеристики имитируемой поверхности кирпичной кладки. Вектор $\color{orange}{\bar{V}}$ представляет собой направление от поверхности на наблюдателя (viewDir). Если бы плоскость действительно была рельефной, то наблюдатель увидел бы точку поверхности $\color{blue}B$. Однако, по факту мы имеем идеальную плоскость и луч по направлению взгляда пересекает плоскость в точке $\color{green}A$, что очевидно. Задача Parallax Mapping сместить текстурные координаты в точке $\color{green}A$ так, чтобы они стали идентичны координатам, соответствующим точке $\color{blue}B$. Далее для текущего фрагмента (соответствует точке $\color{green}A$) мы используем полученные координаты точки $\color{blue}B$ для всех необходимы текстурных выборок, что и создает иллюзию, будто наблюдатель видит точку $\color{blue}B$.

Основная сложность заключена в том, как вычислить текстурные координаты точки $\color{blue}B$ находясь в точке $\color{green}A$. Parallax Mapping предлагает приближенное решение, применяя простое масштабирование вектора направления от поверхности $\color{orange}{\bar{V}}$ к наблюдателю на величину высоты для фрагмента $\color{green}A$. Т.е. просто меняем длину $\color{orange}{\bar{V}}$ так, чтобы она соответствовала величине выборки из карты высот $\color{green}{H(A)}$, соответствующей фрагменту $\color{green}A$. На схеме ниже показан результат масштабирования – вектор $\color{brown}{\bar{P}}$:


Далее результирующий вектор $\color{brown}{\bar{P}}$ раскладывается на компоненты в соответствии с системой координат самой плоскости, которые используются как смещения для исходных текстурных координат. При этом, поскольку вектор $\color{brown}{\bar{P}}$ вычисляется с использованием величины из карты высот, то чем больше значение высоты соответствует текущему фрагменту, тем сильнее для него будет смещение.

Этот простой прием дает неплохие результаты в ряде случаев, но все же является очень грубой оценкой положения точки $\color{blue}B$. Если карта высот содержит участки с резко меняющимися значениями, то результат смещения становится некорректным: скорее всего вектор $\color{brown}{\bar{P}}$ даже близко не будет попадать в окрестность точки $\color{blue}B$:

Исходя из вышеописанного, остается еще один вопрос: каким же образом определить, как корректно спроецировать вектор $\color{brown}{\bar{P}}$ на произвольно сориентированную поверхность, чтобы получить компоненты для смещения текстурных координат? Было бы неплохо вести расчеты в некой системе координат, где разложение вектора $\color{brown}{\bar{P}}$ на компоненты x и y всегда бы соответствовало базису текстурной системы координат. Если вы внимательно проработали урок по Normal Mapping, то уже догадались, что речь идет о расчетах в касательном пространстве.

Переведя вектор от поверхности к наблюдателю $\color{orange}{\bar{V}}$ в касательное пространство мы получим измененный вектор $\color{brown}{\bar{P}}$, покомпонентное разложение которого всегда будет вестись в соответствии с векторами касательной и бикасательной для данной поверхности. Поскольку касательная и бикасательная всегда сонаправлены с осями текстурной системы координат поверхности, то, независимо от ориентации поверхности, можно спокойно использовать компоненты x и y вектора $\color{brown}{\bar{P}}$ как смещения для текстурных координат.

Однако, довольно теории и, закатав рукава, перейдем к непосредственной реализации.

Parallax Mapping


Для реализации мы будем использовать простую плоскость с рассчитанными для нее касательной и бикасательной – это мы уже умеем делать по уроку о Normal Mapping. Плоскости мы назначим ряд текстурных карт: диффузную, нормалей и смещения, файл каждой из которых доступен по соответствующей ссылке. В уроке мы также применим и Normal Mapping, поскольку Parallax Mapping создает иллюзию рельефа поверхности, которая легко нарушается, если освещение не будет изменяться соответственно рельефу. Поскольку карты нормалей зачастую создаются на основе карт высот, то их совместное применение гарантирует корректную связь освещения с учетом рельефа.

Вы, вероятно, уже заметили, что карта смещения, приведенная по ссылке выше, по факту представляет собой инверсию карты, приведенной в начале урока. Реализация Parallax Mapping обычно ведется как раз с использованием таких карт, обратным к картам высот – карт глубин. Так получается, поскольку имитация углублений на плоскости осуществляется несколько легче, чем имитация возвышения. В соответствии с этим изменением меняется и схема работы Parallax Mapping:


Снова мы видим знакомые точки $\color{green}A$ и $\color{blue}B$, однако, в этот раз вектор $\color{brown}{\bar{P}}$ получается вычитанием вектора $\color{orange}{\bar{V}}$ из текстурных координат в точке $\color{green}A$. Значения глубины вместо высоты можно получить просто вычитая выборку глубины из единицы или инвертировав цвета текстуры в любом редакторе изображений.

Parallax Mapping реализуется в фрагментном шейдере, поскольку данные о рельефе отличаются для каждого фрагмента внутри треугольника. Код фрагментного шейдера потребует расчета вектора от фрагмента к наблюдателю $\color{orange}{\bar{V}}$, так что понадобится передать ему положение фрагмента и наблюдателя в касательном пространстве. По результатам урока о Normal Mapping у нас на руках остался вершинный шейдер, который передает все эти вектора уже приведенными к касательному пространству, воспользуемся им:

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in vec3 aTangent;
layout (location = 4) in vec3 aBitangent;

out VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    vec3 TangentLightPos;
    vec3 TangentViewPos;
    vec3 TangentFragPos;
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;

uniform vec3 lightPos;
uniform vec3 viewPos;

void main()
{
    gl_Position      = projection * view * model * vec4(aPos, 1.0);
    vs_out.FragPos   = vec3(model * vec4(aPos, 1.0));   
    vs_out.TexCoords = aTexCoords;    
    
    vec3 T   = normalize(mat3(model) * aTangent);
    vec3 B   = normalize(mat3(model) * aBitangent);
    vec3 N   = normalize(mat3(model) * aNormal);
    mat3 TBN = transpose(mat3(T, B, N));

    vs_out.TangentLightPos = TBN * lightPos;
    vs_out.TangentViewPos  = TBN * viewPos;
    vs_out.TangentFragPos  = TBN * vs_out.FragPos;
}   

Из важного отмечу только то, что конкретно под нужды Parallax Mapping необходимо передать во фрагментный шейдер aPos и положение наблюдателя viewPos в касательном пространстве.

Внутри шейдера реализуем алгоритм Parallax Mapping, что выглядит примерно так:

#version 330 core
out vec4 FragColor;

in VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    vec3 TangentLightPos;
    vec3 TangentViewPos;
    vec3 TangentFragPos;
} fs_in;

uniform sampler2D diffuseMap;
uniform sampler2D normalMap;
uniform sampler2D depthMap;
  
uniform float height_scale;
  
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir);
  
void main()
{           
    vec3 viewDir   = normalize(fs_in.TangentViewPos - fs_in.TangentFragPos);
    // получить смещенные текстурные координаты с помощью Parallax Mapping
    vec2 texCoords = ParallaxMapping(fs_in.TexCoords,  viewDir);

    // делаем выборку из использующихся текстур 
    // с использованием смещенных координат
    vec3 diffuse = texture(diffuseMap, texCoords);
    vec3 normal  = texture(normalMap, texCoords);
    normal = normalize(normal * 2.0 - 1.0);
    // далее – обычный расчет модели освещения
    [...]    
} 

Мы объявили функцию ParallaxMapping, которая принимает текстурные координаты фрагмента и вектор от фрагмента на наблюдателя $\color{orange}{\bar{V}}$ в касательном пространстве. Результатом функции становятся смещенные текстурные координаты, которые уже и используются для выборок из диффузной текстуры и карты нормалей. В результате диффузный цвет пикселя и его нормаль корректно соответствуют измененной «геометрии» плоскости.

Что же скрывается внутри функции ParallaxMapping?

 vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{ 
    float height =  texture(depthMap, texCoords).r;    
    vec2 p = viewDir.xy / viewDir.z * (height * height_scale);
    return texCoords - p;    
}

Данная относительно простая функция является буквальной реализацией метода, основные моменты которого мы обсудили выше. Берутся исходные текстурные координаты texCoords, с помощью них делается выборка высоты (или глубины) $\color{green}{H(A)}$ из depthMap для текущего фрагмента. Для расчета вектора $\color{brown}{\bar{P}}$ берется вектор viewDir в касательном пространстве и пара его компонент x и y делится на компоненту z, а результат масштабируется считанным значением смещения height. Также введен юниформ height_scale для дополнительной возможности управления степенью выраженности эффекта Parallax Mapping, поскольку обычно эффект смещения выходит слишком сильным. Для получения результата мы вычитаем полученный вектор $\color{brown}{\bar{P}}$ из исходных текстурных координат.

Разберемся с моментом деления viewDir.xy на viewDir.z. Поскольку вектор viewDir нормализован, то его компонента z лежит в интервале [0, 1]. Когда вектор практически параллелен поверхности компонента z близка к нулю, а операция деления возвращает вектор $\color{brown}{\bar{P}}$ гораздо большей длины, чем в случае если viewDir близок к перпендикуляру к поверхности. Другими словами, мы масштабируем вектор $\color{brown}{\bar{P}}$ так, чтобы он увеличивался при взгляде на поверхность под углом – это позволяет получить более реалистичный результат в таких случаях.

Некоторые разработчики предпочитают убирать масштабирование делением на viewDir.z, поскольку, в определенных случаях, такой подход дает некорректные результаты при взгляде под углом. Эта модификация техники называется Parallax Mapping with Offset Limiting. Выбор варианта подхода, по большей части, остается делом личных предпочтений – я, например, более лоялен к результатам работы обычного алгоритма Parallax Mapping.

Результирующие измененные текстурные координаты в итоге используются для выборки из диффузной карты и карты нормалей, что дает нам довольно неплохой эффект искажения поверхности (параметр height_scale здесь выбран близким к 0.1):


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

Также видны и странные артефакты вдоль границ оттекстурированной плоскости. Появляются они вследствие того, что смещенные алгоритмом Parallax Mapping текстурные координаты могут выпасть за пределы единичного интервала и, в зависимости от режима повторения текстуры (wrapping mode), вызвать появление нежелательных результатов. Простой способ избавления от таких артефактов – просто отбросить все фрагменты, для которых текстурные координаты оказались вне единичного интервала:

texCoords = ParallaxMapping(fs_in.TexCoords,  viewDir);
if(texCoords.x > 1.0 || texCoords.y > 1.0 || texCoords.x < 0.0 || texCoords.y < 0.0)
    discard; 

В итоге все фрагменты, имеющие смещенные текстурные координаты, выпадающие из интервала [0, 1], будут отброшены и визуально результат действия Parallax Mapping станет приемлемым. Очевидно, что этот метод отбраковки не универсален и может быть неприменим для некоторых поверхностей или случаев текстурирования. Но на примере плоскости он работает идеально и помогает усилить эффект изменения рельефа плоскости:


Исходники примера находятся здесь.

Выглядит неплохо, да и производительность метода отличная – всего то потребовалась одна дополнительная выборка из текстуры! Но простота метода имеет и значительные недостатки: эффект рельефности легко разрушается при взгляде на плоскость под углом (что верно и для Normal Mapping) или при наличии в карте высот участков с резкими перепадами значений:


Причина разрушения иллюзии кроется в том, что алгоритм является весьма грубым приближением реального Displacement Mapping. Однако, нас могут выручить несколько дополнительных приемов, которые позволяют получить практически идеальные результаты даже при взгляде под углом или при использовании карт высот с резкими перепадами. Например, мы можем использовать несколько выборок из карты высот, дабы найти точку, ближайшую к точке $\color{blue}B$.

Steep Parallax Mapping


Техника Steep Parallax Mapping является логическим развитием классического Parallax Mapping: используется такой же подход в алгоритме, но вместо единственной выборки берется несколько – для лучшей аппроксимации вектора $\color{brown}{\bar{P}}$, использующегося для расчета точки $\color{blue}B$. За счет этих дополнительных выборок результат работы алгоритма визуально гораздо более правдоподобен, даже в условиях взгляда под острыми углами к поверхности.

Основа подхода Steep PM заключается в том, чтобы взять некоторый диапазон глубин и разбить его на равные по размеру слои. Далее мы итеративно проходим по слоям одновременно смещая исходные текстурные координаты в направлении вектора $\color{brown}{\bar{P}}$ и делая выборки из карты глубин, останавливаясь в тот момент, когда глубина из выборки оказывается меньше, чем глубина текущего слоя. Ознакомьтесь со схемой:


Как видно, мы движемся по слоям сверху вниз и для каждого слоя сравниваем его глубину со значением из карты глубин. Если глубина слоя оказывается меньше значения из карты глубин, это значит, что вектор $\color{brown}{\bar{P}}$, соответствующий этому слою, лежит выше поверхности. Этот процесс повторяется до тех пор, пока глубина слоя не оказывается больше выборки из карты глубин: в этот момент вектор $\color{brown}{\bar{P}}$ указывает на точку, лежащую под имитируемым рельефом поверхности.
В примере видно, что выборка из карты глубин на втором слое ($D(2)=0.73$) все еще лежит «глубже» по отношению к величине глубины второго слоя равной 0.4, а значит процесс поиска продолжается. В следующем проходе глубина слоя равная 0.6 наконец оказывается лежащей «выше» значения выборки из карты глубин ($D(3)=0.37$). Отсюда мы делаем вывод, что вектор $\color{brown}{\bar{P}}$ полученный для третьего слоя является наиболее достоверным положением для искаженной геометрии поверхности. Можно использовать текстурные координаты $T_3$, полученные из вектора $\color{brown}{\bar{P_3}}$, для смещения текстурных координат текущего фрагмента. Очевидно, что точность метода растет с количеством слоев.

Изменения в реализации затронут только функцию ParallaxMapping, поскольку в ней уже собраны все необходимые для работы алгоритма переменные:

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{ 
    // количество слоев глубины
    const float numLayers = 10;
    // размер каждого слоя
    float layerDepth = 1.0 / numLayers;
    // глубина текущего слоя
    float currentLayerDepth = 0.0;
    // величина шага смещения текстурных координат на каждом слое
    // расчитывается на основе вектора P
    vec2 P = viewDir.xy * height_scale; 
    vec2 deltaTexCoords = P / numLayers;
  
    [...]     
}    

Сперва осуществим инициализацию: установим количество слоев, рассчитаем глубину каждого из них и, в итоге, найдем размер смещения текстурных координат вдоль направления вектора $\color{brown}{\bar{P}}$, на которое необходимо будет смещаться на каждом слое.

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


// начальная инициализация
vec2  currentTexCoords     = texCoords;
float currentDepthMapValue = texture(depthMap, currentTexCoords).r;
  
while(currentLayerDepth < currentDepthMapValue)
{
    // смещаем текстурные координаты вдоль вектора P
    currentTexCoords -= deltaTexCoords;
    // делаем выборку из карты глубин в текущих текстурных координатах 
    currentDepthMapValue = texture(depthMap, currentTexCoords).r;  
    // рассчитываем глубину следующего слоя
    currentLayerDepth += layerDepth;  
}

return currentTexCoords; 

В данном коде мы осуществляем проход по всем слоям глубины и смещаем исходные текстурные координаты, до тех пор, пока выборка из карты глубин не станет меньше глубины текущего слоя. Смещение осуществляется вычитанием из исходных текстурных координат дельты, основанной на векторе $\color{brown}{\bar{P}}$. Результатом работы алгоритма становится смещенный вектор текстурных координат, определенный с гораздо большей точностью, нежели классический Parallax Mapping.

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


Можно улучшить алгоритм еще немного, если немного проанализировать особенности техники Parallax Mapping. Если смотреть на поверхность примерно по нормали, то необходимость сильно смещать текстурные координаты нет, в то время как при взгляде под углом смещение стремится к максимуму (мысленно представьте направление взгляда в обоих случаях). Если параметризировать число выборок в зависимости от направления взгляда, то можно неплохо сэкономить там, где лишние выборки не нужны:

const float minLayers = 8.0;
const float maxLayers = 32.0;
float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir)));   

Результат скалярного произведения вектора viewDir и положительной полуоси Z используется для определения числа слоев в интервале [minSamples, maxSamples], т.е. направление взгляда определяет необходимое число итераций эффекта (в касательном пространстве положительная полуось Z направлена по нормали к поверхности). Если мы бы мы взглянули параллельно поверхности, то эффект бы использовал все 32 слоя.

Модифицированный исходный код находится здесь. Также предлагаю к скачиванию текстуры деревянной игрушки: диффузная, карта нормалей, карта глубин.

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


Можно снизить выраженность артефакта путем наращивания количества используемых сэмплов, но это довольно быстро сожрет всю доступную производительность видеопроцессора. Существует несколько дополнений к методу, которые возвращают как результат не первую же точку, оказавшуюся под мнимым рельефом поверхности, а интерполированное значение двух ближайших слоев, что позволяет еще немного уточнить положение точки $\color{blue}B$.

Из этих методов чаще всего используются два: Relief Parallax Mapping и Parallax Occlusion Mapping, причем Relief PM дает наиболее достоверные результаты, но и является немного более требовательным к производительности, по сравнению с Parallax Occlusion Mapping. Поскольку Parallax Occlusion Mapping все же довольно близок по качеству к Relief PM и при этом работает быстрее, то его предпочитают использовать чаще всего. Далее будет рассмотрена реализация именно Parallax Occlusion Mapping.

Parallax Occlusion Mapping


Метод Parallax Occlusion Mapping работает все на тех же базовых принципах, что и Steep PM, но вместо использования текстурных координат первого же слоя, где было обнаружено пересечение с мнимым рельефом, метод использует линейную интерполяцию между двумя слоями: слоем после и до пересечения. Весовой коэффициент для линейной интерполяции основывается на величине отношения текущей глубины рельефа к глубинам обоих рассматриваемых слоев. Посмотрите на схему, чтобы понять получше, как все работает:


Как видно, все очень схоже с Steep PM, добавляется лишь один дополнительный шаг интерполяции текстурных координат двух слоев глубины, соседствующих с точкой пересечения. Конечно, и такой метод является всего лишь приближением, но куда более точным, нежели Steep PM.

Код, отвечающий за дополнительные действия Parallax Occlusion Mapping является дополнением к коду Steep PM и не слишком сложен:


[...] // расчеты, относящиеся к методу Steep PM 
  
// находим текстурные координаты перед найденной точкой пересечения,
// т.е. делаем "шаг назад"
vec2 prevTexCoords = currentTexCoords + deltaTexCoords;

// находим значения глубин до и после нахождения пересечения 
// для использования в линейной интерполяции
float afterDepth  = currentDepthMapValue - currentLayerDepth;
float beforeDepth = texture(depthMap, prevTexCoords).r - currentLayerDepth + layerDepth;
 
// интерполяция текстурных координат 
float weight = afterDepth / (afterDepth - beforeDepth);
vec2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight);

return finalTexCoords;   

В момент нахождения слоя, лежащего после точки пересечения с мнимым рельефом, мы также определяем текстурные координаты слоя, лежащего до точки пересечения. Далее находим смещения глубины мнимого рельефа относительно глубин двух рассматриваемых слоев и используем их отношение как весовой коэффициент дальнейшей линейной интерполяции текстурных координат, соответствующих двум рассмотренным слоям. Результат интерполяции возвращается функцией для дальнейшего использования.

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


Код примера находится здесь.

Parallax Mapping воистину великолепная техника, позволяющая резко повысить визуальную детализацию вашей сцены, но, безусловно, имеет свои недостатки в виде артефактов, о чем стоит помнить при реализации техники в проекте. По большей части Parallax Mapping используется на плоских поверхностях типа стен или полов – там, где не так то просто определить очертания объекта целиком, а угол взгляда на поверхность часто близок к перпендикуляру. В таком случае огрехи Parallax Mapping практически незаметны, на фоне возросшей детальности поверхности.

Бонусы от переводчика:


Relief Parallax Mapping


Раз уж автор упомянул два метода уточнения результата Steep PM, то для полноты картины опишу и второй из подходов.

Как и Parallax Occlusion Mapping здесь используется результат выполнения Steep PM, т.е. нам известны глубины двух слоев между которыми лежит реальная точка пересечения вектора $\color{orange}{\bar{V}}$ с рельефом, а также соответствующие им текстурные координаты $T_2$ и $T_3$. Уточнение оценки точки пересечения в данном методе идет за счет применения бинарного поиска.

Шаги алгоритма уточнения:

  • Выполняем расчет Steep PM и получаем текстурные координаты $T_2$ и $T_3$ – в этом интервале лежит точка пересечения вектора $\color{green}{\bar{V}}$ с рельефом поверхности. Истинная точка пересечения отмечена красным крестиком.
  • Разделить на два текущие значения смещения текстурных координат и высоты слоя глубины.
  • Сместить текстурные координаты из точки $T_3$ в направлении обратном вектору $\color{green}{\bar{V}}$ на величину смещения. Уменьшить глубину слоя на текущее значение размера слоя.
  • Непосредственно бинарный поиск. Повторяется заданное число итераций:
    1. Осуществить выборку из карты глубин. Разделить на два текущие значения смещения текстурных координат и размера слоя глубины.
    2. Если величина выборки оказалась больше текущей глубины слоя, то увеличить глубину слоя на текущий размер слоя, а текстурные координаты изменить вдоль вектора $\color{green}{\bar{V}}$ на текущее смещение.
    3. Если же величина выборки оказалась меньше текущей глубины слоя, то уменьшить глубину слоя на текущий размер слоя, а текстурные координаты изменить вдоль вектора обратного $\color{green}{\bar{V}}$ на текущее смещение.

  • Последние полученные текстурные координаты и есть результаты Relief PM.

На изображении видно, что после нахождения точек $T_2$ и $T_3$ мы уполовиниваем размер слоя и размер смещения текстурных координат, что дает нам первую точку итерации бинарного поиска (1). Так как величина выборки в ней оказалась больше, чем текущая глубины слоя, то мы еще раз уполовиниваем параметры и смещаемся вдоль $\color{green}{\bar{V}}$, получая точку (2) с текстурными координатами $T_p$, что и будет результатом Steep PM для двух итераций бинарного поиска.

Шейдерный код:


// входные параметры: inTexCoords  - исходные текстурные координаты, 
// inViewDir  - вектор на наблюдателя в касательном пр-ве
// выходные параметры: lastDepthValue – глубина в найденной точке пересечения
// функция возвращает измененные текстурные координаты
vec2 reliefPM(vec2 inTexCoords, vec3 inViewDir, out float lastDepthValue) {
// ======
// код, повторяющий реализацию Steep PM 
// ======
	const float _minLayers = 2.;
	const float _maxLayers = 32.;
	float _numLayers = mix(_maxLayers, _minLayers, abs(dot(vec3(0., 0., 1.), inViewDir)));

	float deltaDepth = 1./_numLayers;
// uDepthScale – юниформ для контроля выраженности PM
	vec2 deltaTexcoord = uDepthScale * inViewDir.xy/(inViewDir.z * _numLayers);

	vec2 currentTexCoords = inTexCoords;
	float currentLayerDepth = 0.;
	float currentDepthValue = depthValue(currentTexCoords);
	while (currentDepthValue > currentLayerDepth) {
		currentLayerDepth += deltaDepth;
		currentTexCoords -= deltaTexcoord;
		currentDepthValue = depthValue(currentTexCoords);
	}
// ======
// код реализации Relief PM 
// ======

// уполовиниваем смещение текстурных координат и размер слоя глубины
	deltaTexcoord *= 0.5;
	deltaDepth *= 0.5;
// сместимся в обратном направлении от точки, найденной в Steep PM
	currentTexCoords += deltaTexcoord;
	currentLayerDepth -= deltaDepth;

// установим максимум итераций поиска…
	const int _reliefSteps = 5;
	int currentStep = _reliefSteps;
	while (currentStep > 0) {
		currentDepthValue = depthValue(currentTexCoords);
		deltaTexcoord *= 0.5;
		deltaDepth *= 0.5;
// если выборка глубины больше текущей глубины слоя, 
// то уходим в левую половину интервала
		if (currentDepthValue > currentLayerDepth) {
			currentTexCoords -= deltaTexcoord;
			currentLayerDepth += deltaDepth;
		}
// иначе уходим в правую половину интервала
		else {
			currentTexCoords += deltaTexcoord;
			currentLayerDepth -= deltaDepth;
		}
		currentStep--;
	}

	lastDepthValue = currentDepthValue;
	return currentTexCoords;
}

Самозатенение


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

По сути применяется тот же Steep PM, но с поиск идет не вглубь имитируемой поверхности вдоль линии взгляда, а из поверхности, вдоль вектора на источник света $\color{green}{\bar{L}}$. Этот вектор также переносится в касательное пространство и используется для определения величины смещения текстурных координат. На выходе метода получится вещественный коэффициент освещенности в интервале [0, 1], который используется для модуляции диффузной и зеркальной компонент в расчетах освещения.
Для определения затенения с резкими краями достаточно пройтись вдоль вектора $\color{green}{\bar{L}}$ до тех пор, пока не найдется точка, лежащая под поверхностью. Как только такая точка найдена – принимаем коэффициент освещенности 0. Если же мы достигли нулевой глубины, не встретив точки, лежащей под поверхностью, то коэффициент освещенности берем равным 1.

Для определения затенения с мягкими краями необходимо проверить несколько точек, лежащих на векторе $\color{green}{\bar{L}}$ и находящиеся под поверхностью. Коэффициент затенения при этом берется равным разности глубины текущего слоя и глубины из карты глубин. Также учитывается удаление очередной точки от рассматриваемого фрагмента в виде весового коэффициента равного (1.0 – stepIndex/numberOfSteps). На каждом шаге определяется частичный коэффициент освещенности как:

$PSF_i=(layerHeight_i - heightFromtexture_i)*(1.0-\frac{i}{numSteps})$


Конечным же результатом является максимальный коэффициент освещенности из всех частичных:

$SF=max(PSF_i)$


Схема работы метода:

Ход работы метода для трех итераций в данном примере:

  • Инициализируем итоговый коэффициент освещенности в ноль.
  • Делаем шаг вдоль вектора $\color{green}{\bar{L}}$, попадая в точку $H_a$. Глубина точки явно меньше, чем выборка из карты $H(T_{L1})$ – она находится под поверхностью. Здесь мы сделали первую проверку и, помня об общем числе проверок, находим и сохраняем первый частичный коэффициент освещенности: (1.0 – 1.0/3.0).
  • Делаем шаг вдоль вектора $\color{green}{\bar{L}}$, попадая в точку $H_b$. Глубина точки явно меньше, чем выборка из карты $H(T_{L2})$ – она находится под поверхностью. Вторая проверка и второй частичный коэффициент: (1.0 – 2.0/3.0).
  • Делаем еще один шаг вдоль вектора и попадаем на минимальную глубину 0. Останавливаем движение.
  • Определение результата: если точек под поверхностью не было найдено, то возвращаем коэффициент равный 1 (отсутствие затенения). Иначе результирующим коэффициентом становится максимальный из вычисленных частичных. Для использования в расчете освещения мы вычитаем это значение из единицы.

Пример шейдерного кода:


// входные параметры: inTexCoords  - исходные текстурные координаты, 
// inLightDir - вектор на источник света в касательном пр-ве
// выходные параметры: inLastDepth – глубина в точке пересечения, 
// найденной одним из улучшенных методов PM
// функция возвращает коэффициент для модуляции компонент 
// использующейся модели освещения 
float getParallaxSelfShadow(vec2 inTexCoords, vec3 inLightDir, float inLastDepth) {
	float shadowMultiplier = 0.;
// расчет будем делать только для поверхностей, 
// освещенных используемым источником
	float alignFactor = dot(vec3(0., 0., 1.), inLightDir);
	if (alignFactor > 0.) {
// знакомая инициализация параметров: слои глубины, шаг 
// слоя глубины, шаг смещения текстурных координат
		const float _minLayers = 16.;
		const float _maxLayers = 32.;
		float _numLayers = mix(_maxLayers, _minLayers, abs(alignFactor));
		float _dDepth = inLastDepth/_numLayers;
		vec2 _dtex = uDepthScale * inLightDir.xy/(inLightDir.z * _numLayers);

// счетчик точек, оказавшихся под поверхностью
		int numSamplesUnderSurface = 0;

// поднимаемся на глубину слоя и смещаем 
// текстурные координаты вдоль вектора L
		float currentLayerDepth = inLastDepth - _dDepth;
		vec2 currentTexCoords = inTexCoords + _dtex;

		float currentDepthValue = depthValue(currentTexCoords);

// номер текущего шага
		float stepIndex = 1.;
// повторяем, пока не выйдем за слой нулевой глубины…
		while (currentLayerDepth > 0.) {
// если нашли точку под поверхностью, то увеличим счетчик и 
// рассчитаем очередной частичный и полный коэффициенты
			if (currentDepthValue < currentLayerDepth) {
				numSamplesUnderSurface++;
				float currentShadowMultiplier = (currentLayerDepth - currentDepthValue)*(1. - stepIndex/_numLayers);

				shadowMultiplier = max(shadowMultiplier, currentShadowMultiplier);
			}
			stepIndex++;
			currentLayerDepth -= _dDepth;
			currentTexCoords += _dtex;
			currentDepthValue = depthValue(currentTexCoords);
		}
// если точек под поверхностью не было, то точка 
// считается освещенной и коэффициент оставим 1
		if (numSamplesUnderSurface < 1)
			shadowMultiplier = 1.;
		else
			shadowMultiplier = 1. - shadowMultiplier;
	}

	return shadowMultiplier;
}

Полученный коэффициент используется для модуляции результата работы используемой в примерах модели освещения Блинна-Фонга:

	
[...] // расчет отдельных компонент модели освещения
vec3 fullColorADS = ambientTerm + attenuation * (diffuseTerm + specularTerm);
// модулируем результат с помощью к-та самозатенения
fullColorADS *= pmShadowMultiplier;
return fullColorADS;

Сравнение всех методов в одном коллаже, объем 3Мб.

Также видео-сравнение:


Дополнительные материалы


Комментарии (7)


  1. Boroda1
    18.07.2018 19:31
    +1

    Интересная статья.
    Но по ходу прочтения невольно возникает вопрос:

    Начиная с normal-mapping и до POM — идёт весьма заметное усложнение шейдера. Сначала одну выборку, потом несколько, потом интерполяция между слоями, потом самозатенение и так далее, улучшать и усложнять можно до бесконечности.
    По сути, вся сложность вместо построения реальной геометрии с текстурой постепенно переносится в шейдер, имитирующий эту самую реальную геомтрию.
    Вопрос:
    Насколько реальная low-poly геометрия той же кирпичной стены + текстура + самый простенький normal-mapping будет медленнее самого продвинутого варианта POM?
    Иными словами, действительно ли выгода от этих подходов на сегодня столь ощутима?


    1. UberSchlag Автор
      18.07.2018 20:44

      Предполагаю, что зависит все от конкретной фактуры поверхности, от наличия высокочастотных элементов в карте высот. Если кирпичи и несложно сделать геометрией, то галечную насыпь, условно, делать это неоправданно тяжело.
      Плюс бюджет полигонов в кадре не резиновый и лучше дать больший запас важным элементами сцены — персонажам в основном — чем стеночкам. В общем мощность GPU растет и лучше моделлеров занять лучше чем-то более осмысленным.


      1. Boroda1
        18.07.2018 21:02

        Дело в том, что работа шейдерных процессоров тоже не «бесплатная», это тоже вычисления.
        И явно существует предел сложности шейдера, за которым он становится неоправданно ресурсозатратным. Вот мне и хотелось бы узнать, на данный момент насколько ещё далеко до этого предела?
        Насколько геометрия (пусть даже не замоделенная руками, а динамически построенная из той же карты высот) проигрывает шейдерам, имитирующим её.


  1. MicroSDA
    18.07.2018 23:27

    Статья хорошая, когда-то занимался таким, но остановился на этом месте и на кватернионах для анимаций. Используете ли вы понятие материала в освещении и как это может улучшить или ухудшить данных процесс описанный выше?


    1. UberSchlag Автор
      19.07.2018 09:31

      Это переводная статья. Автор использует понятие материала не более чем как структуру, содержащую текстурные объекты и параметры, необходимые для используемой модели освещения Блинна-Фонга. В уроках по PBR «материалом» становится уже набор текстур, используемый в так называемом «metallic workflow».
      Если вы что-то другое имеете в виду под «материалом», то напишите.


      1. MicroSDA
        19.07.2018 13:32

        Автор использует понятие материала не более чем как структуру, содержащую текстурные объекты и параметры, необходимые для используемой модели освещения Блинна-Фонга
        Да именно это, спасибо.


  1. MicroSDA
    19.07.2018 13:31

    del