Точный учет микрорельефа при помощи parallax и relief mapping'а

Характерной чертой развития 3D-графики является все возрастающая степень детализации объектов. Первым шагом в такой детализации было применение bumpmapping-а (впервые предложенное Блинном).

Сейчас это стало повсеместно распространенным использовать bumpmapping, т.е. попиксельное задание вектора нормали, для моделирования неровностей поверхности (ее микрорельефа). За счет этого удается не усложняя геометрию объектов создать иллюзию микрорельфа, т.е. высокой степени детализации.

bumpmapping example

Рис 1. Пример использования bumpmapping-а.

Однако bumpmapping дает четко видимые результаты либо для освещения небольшим количеством точечных источников света (при этом желательно чтобы поверхности имели сильно выраженную бликовую компоненту как в DooM 3), либо при отражении (EMBM).

Случай же объекта, освещенного рассеянным светом (например объекта, находящегося под открытым небом в облачную погоду) традиционным bumpmapping -ом вообще не обрабатывается.

Однако есть и другие проявления микрорельефа поверхности - изменение текстурных координат точек в зависимости направления на наблюдателя, самозатенение и т.п.

В данной статье мы рассмотрим моделирование подобных явлений начиная с самых простых и заканчивая наиболее продвинутыми методами.

Проще всего задавать микрорельеф поверхности при помощи так называемой карты высот (height map) h(s,t). При этом возможны два варианта трактовки этой карты. Значения из нее могут рассматриваться либо как высота точки над гранью (рис 2а), либо как углубление внутрь грани (рис 2b). Мы далее будем придерживаться первой интерпретации.

heightmap interpretations

Рис 2. Возможные интерпретации карты высот.

Parallax Mapping

Простейшим случаем моделирования рельефа (не считая bumpmapping-а) является так называемый parallax (relief) mapping - простой способ коррекции текстурных координат с использованием карты высот.

Когда неровная поверхность (задаваемая картой высот h(s,t)) рассматривается с направления v, то если мы не учитываем микрорельефа поверхности, в качестве точки на ней будет взята точка А (точнее, соответствующие этой точке текстурные координаты). Однако точному пересечению луча от наблюдателя соответствует точка В (и соответствующая ей точка T* в текстурных координатах).

Рис. 3. Приближенное вычисление точки на поверхности.

Подобное явления - т.е. изменение текстурных координат в зависимости от направления на наблюдателя в связи с микрорельефом поверхности, и называется parallax mapping. Обратите внимание, что все вычисления проводятся в касательном пространстве грани (tangent space), поэтому важно, чтобы для каждой грани было правильно задано касательное пространство.

Можно использовать значение высоты в точке А для получения более точного значения текстурных координат (T1):

parallax mapping equation

В этой формуле через v обозначен единичный вектор на наблюдателя в касательной системе координат, т.е. vxy - это касательная компонента, а vz - нормальная. Через h обозначено значение из карты высоты в точке T0.

Коррекцию текстурных координат по приведенной формуле можно довольно легко реализовать при помощи простых вершинных и фрагментных шейдеров.

//
// Simple parallax effect vertex shader
//

varying	vec3 lt;                                      // vector to light in tangent space
varying	vec3 et;                                      // vector to eye in tangent space

uniform	vec4	light;                                // light coordinates in world space
uniform	vec4	eye;                                  // camera (eye) coordinates in world space

void main(void)
{
                                                      // transformed point to world space
    vec3 p = vec3      ( gl_ModelViewMatrix * gl_Vertex );

    vec3 l = normalize ( vec3 ( light ) - p );         // vector to light source
    vec3 e = normalize ( vec3 ( eye   ) - p );         // vector to the eye
    vec3 n = gl_NormalMatrix * gl_Normal;              // transformed n

    vec3 t = gl_NormalMatrix * gl_MultiTexCoord1.xyz;  // transformed t
    vec3 b = gl_NormalMatrix * gl_MultiTexCoord2.xyz;  // transformed b

                                                       // now remap l, and e into
                                                       // tangent space
    et = vec3 ( dot ( e, t ), dot ( e, b ), dot ( e, n ) );
    lt = vec3 ( dot ( l, t ), dot ( l, b ), dot ( l, n ) );

    gl_Position     = gl_ModelViewProjectionMatrix * gl_Vertex;
    gl_TexCoord [0] = gl_MultiTexCoord0;
}

//
// Simple parallax effect fragment shader
//

varying	vec3 et;                                       // vector to light in tangent space
varying	vec3 lt;                                       // vector to eye in tangent space

uniform sampler2D decalMap;
uniform sampler2D heightMap;
uniform float     scale;                               // scale to control amount of parallax
uniform float     bias;

void main (void)
{
                                                       // get corrected height
    float   h    = scale * (1.0-texture2D ( heightMap, gl_TexCoord [0] ).a) + bias;

                                                       // now offset texture coordinates
                                                       // with height
    vec2    tex  = gl_TexCoord [0].xy - et.xy * h / et.z;

    gl_FragColor = vec4 ( texture2D ( decalMap, tex ).rgb, 1.0 );
}

simple parallax on simple parallax off

Рис 4. Изображение, получаемое при помощи приведенных шейдеров (слева - параллакс включен, справа - нет).

Однако данный способ коррекции текстурных координат является лишь приближенным, в частности когда вектор v почти касается поверхности (т.е. vz << 1), то использование рассмотренной ранее формулы приводит к большим погрешностям. Поэтому иногда вместо этой формулы используют следующую упрощенную формулу, вообще отказавшись от деления (parallax with offset limiting)

Кроме того в качестве высоты h обычно используется смещенное значение:

Ниже приводится изображение поверхности, полученное при помощи этого способа.

simple parallax with offset limiting

Рис 5. Parallax with offset limiting.

Однако рассмотренный выше parallax mapping дает лишь приближенное значение, годящееся для поверхностей без резких изменений высоты, в противном случае он может давать ошибочные значения. Так на рис. 3 вместо точного значения T*, соответствующего пересечению луча с поверхностью, мы берем значение T1 (хотя это лучше чем исходное значение T0).

Steep Parallex Mapping

Одним из крайне простых способов улучшения точности классического parallax mapping'-а является разбиение диапазона значений высот на несколько равных отрезков (слоев). Тогда можно путем простого перебора слоев (начиная с самого внешнего, т.е. соответствующего наибольшему значению высоты, слоя) найти приближенное значение точки на поверхности (рис. 6) - такой подход обычно называется steep parallax mapping.

Рис 6. Нахождение пересечения путем перебора слоев.

В этом случае сначала проверяется точка A0, затем A1, затем A2 и так далее до тех пор, пока очередная точка не окажется ниже соответствующего значения высоты (для рис. 6 такой точкой является A3). Фактически осуществляется трассировка объема, разбитого на слои одинаковой толщины.

Данный алгоритм легко реализуется следующим фрагментным шейдером на GLSL. Параметр scale, как и ранее, управляет "степенью рельефности" - чем он больше, чет сельнее "выпирает" рельеф.

//
// Steep parallax mapping fragment shader
//

varying	vec3 et;                                          // vector to eye in tangent space
varying	vec3 lt;                                          // vector to light in tangent space

uniform sampler2D decalMap;
uniform sampler2D heightMap;
uniform float     scale;

void main (void)
{
    const float numSteps  = 10.0;                         // number of checked layers

    float   step   = 1.0 / numSteps;                      // distance between checked layers
    vec2    dtex   = et.xy * scale / ( numSteps * et.z ); // adjustment for one layer
    float   height = 1.0;                                 // height of the layer
    vec2    tex    = gl_TexCoord [0].xy;                  // our initial guess
    float   h      = texture2D ( heightMap, tex ).a;      // get height

    while ( h < height )                               // check every layer
    {
        height -= step;                                   // update height
        tex    += dtex;                                   // update texture coordinates
        h       = texture2D ( heightMap, tex ).a;         // get new height
    }
                                                          // now offset texture coordinates
                                                          // with height
    gl_FragColor = vec4 ( texture2D ( decalMap, tex ).rgb, 1.0 );
}

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

//
// Steep parallax mapping fragment shader
//

varying	vec3 et;
varying	vec3 lt;

uniform sampler2D decalMap;
uniform sampler2D heightMap;
uniform float     scale;

void main (void)
{
    const float numSteps  = 5.0;

    float step   = 1.0 / numSteps;
    vec2  dtex   = et.xy * scale / ( numSteps * et.z );  // adjustment for one layer
    float height = 1.0;                                  // height of the layer
    vec2  tex    = gl_TexCoord [0].xy;                   // our initial guess
    float h      = texture2D ( heightMap, tex ).a;       // get height

    if ( h < height )
    {
        height -= step;
        tex    += dtex;
        h       = texture2D ( heightMap, tex ).a;

        if ( h < height )
        {
            height -= step;
            tex    += dtex;
            h       = texture2D ( heightMap, tex ).a;

            if ( h < height )
            {
                height -= step;
                tex    += dtex;
                h       = texture2D ( heightMap, tex ).a;

                if ( h < height )
                {
                    height -= step;
                    tex    += dtex;
                    h       = texture2D ( heightMap, tex ).a;

                    if ( h < height )
                    {
                        height -= step;
                        tex    += dtex;
                        h       = texture2D ( heightMap, tex ).a;
                    }
                }
            }
        }
    }
                                            // now offset texture coordinates with height
    gl_FragColor = vec4 ( texture2D ( decalMap, tex ).rgb, 1.0 );
}

Рис 7. Изображение, получаемое при помои перебора набора слоев.

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

Рис 8. Артефакты, возникающие при переборе слоев.

Эта погрешность свзяана с тем, что проверка на пересечение луча проводится высего лишь в небольшом числе слоев, соответствующих дискретному набору высот. В ислу этого при резких изменениях высоты и/или при направлении взгляда, близкому к касательному, подобная "слоистость" поверхности становится явно видимой.

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

int numLayers = (int) mix ( (float) maxLayers, (float) minLayers, abs ( dot ( n, v ) ) );

Также на форуме gamedev.ru предлагалось использовать вместо линейной интерполяции экспоненциальную.

Relief Mapping

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

Рис 9. Уточнение точки при помощи метода половинного деления.

Так для случая с рис. 8 точное значение пересечения лежит между точками A* и A1, поэтому в качестве начального приближения можно взять точку B1=(A1+A*)/2. Если эта точка лежит выше поверхности, задаваемой картой высоты, то корень находится на отрезке [B1, A*], в противном случае корень находится на [A1,B1].

Далее на получившемся отрезке вновь выбираем середину и сравниваем значение в ней со значением высоты. Это сравнение определяет какую половину отрезка следует оставить (т.к. она содержит корень).

Так проводится несколько итераций уточнения корня, при этом на каждой итерации точность удваивается (поскольку длина отрезка, содержащего корень сокращается в два раза на каждой итерации). После проведения фиксированного числа итераций середина последнего отрезка берется в качестве корня. Этот подход получил название relief mapping. Ниже приводится соответствующая фрагментная программа.

//
// Steep parallax mapping with binary search fragment shader
//

varying	vec3 et;
varying	vec3 lt;

uniform sampler2D decalMap;
uniform sampler2D heightMap;
uniform float     scale;

void main (void)
{
    const float numSteps  = 10.0;

    float step   = 1.0 / numSteps;
    vec2  dtex   = et.xy * scale / ( numSteps * et.z ); // adjustment for one layer
    float height = 1.0;                                 // height of the layer
    vec2  tex    = gl_TexCoord [0].xy;                  // our initial guess
    float h      = texture2D ( heightMap, tex ).a;      // get height

    while ( h < height )
    {
        height -= step;
        tex    += dtex;
        h       = texture2D ( heightMap, tex ).a;
    }

                                                         // now add some binary search
    vec2 delta = 0.5 * dtex;
    vec2 mid   = tex - delta;                            // midpoint

    for ( int i = 0; i < 5; i++ )
    {
        delta *= 0.5;

        if ( texture2D ( heightMap, mid ).a < height )
            mid += delta;
        else
            mid -= delta;
    }

    tex = mid;
                                                         // now offset texture coordinates with height
    vec3  color = texture2D ( decalMap, tex ).rgb;
    vec3  n     = normalize ( texture2D ( heightMap, tex ).rgb * 2.0 - vec3 ( 1.0 ) );
    float diff  = max ( dot ( n, normalize ( lt ) ), 0.0 );
    float spec  = pow ( max ( dot ( n, normalize ( ht ) ), 0.0 ), specExp );

    gl_FragColor = vec4 ( color * diff + vec3 ( 0.7 * spec ), 1.0 );
}

В качестве иллюстрации к этому подходу возьмем текстуру из NVSDK, задающую одновременно и карту нормалей (в RGB части) и карту высот (в альфа-канале). Далее построим изображение куба, на каждую сторону которого накладывается текстура камня и карта нормалей(высот). При построении изображения будет не только использовать relief mapping, но и проводить попиксельное вычисление диффузной и бликовой освещенности.

На следующих двух изображениях приведены изображения куба с использованием relief mapping и без него (только попиксельное вычисление освещенности).

Обратите внимание на то, что наложение текстуры на поверхность отличается от обычного bumpmapping'а.

Рис. 10. Попиксельное освещение с использованием relief mapping

Рис. 11. Попиксельное освещение без использования relief mapping

Путем небольшой модификации шейдера можно использовать relief mapping не только для определения точной точки на поверхности, но также и для проверки ее затененности (закрывания микрорельефом от источника света).

Для этого поиск пересечения луча с поверхностью начинается от найденной точки пересечения и ведется в направлении вектора на источник света. Перебор слоев в этом случае осуществляется в обратном направлении (т.е. соответствующем увеличению высоты).

Parallax Occlusion Mapping

Существует еще один вариант развития steep parallax mapping'а, получивший название parallax occlusion mapping.

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

Рис 12. Линейное приближение.

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

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

//
//  Parallax Occlusion maping GLSL fragment shader
//

varying	vec3 et;
varying	vec3 lt;
varying	vec3 ht;

uniform sampler2D decalMap;
uniform sampler2D heightMap;
uniform float     scale;

void main (void)
{
    const float numSteps  = 8.0;
    const float specExp   = 80.0;

    float step   = 1.0 / numSteps;
    vec2  dtex   = et.xy * scale / ( numSteps * et.z ); // adjustment for one layer
    float height = 1.0;                                 // height of the layer
    vec2  tex    = gl_TexCoord [0].xy;                  // our initial guess
    float h      = texture2D ( heightMap, tex ).a;      // get height

    while ( h < height )                             // iterate through layers
    {
        height -= step;
        tex    += dtex;
        h       = texture2D ( heightMap, tex ).a;
    }
                                                        // now find point via linear interpolation
    vec2  prev   = tex - dtex;                          // previous point
    float hPrev  = texture2D ( heightMap, prev ).a - (height + step);
    float hCur   = h - height;
    float weight = hCur / (hCur - hPrev );

    tex = weight * prev + (1.0 - weight) * tex;         // interpolate to get tex coords

    vec3  color = texture2D ( decalMap, tex ).rgb;      // get color and compute lighting
    vec3  n     = normalize ( texture2D ( heightMap, tex ).rgb * 2.0 - vec3 ( 1.0 ) );
    float diff  = max ( dot ( n, normalize ( lt ) ), 0.0 );
    float spec  = pow ( max ( dot ( n, normalize ( ht ) ), 0.0 ), specExp );

    gl_FragColor = vec4 ( color * diff + vec3 ( 0.7 * spec ), 1.0 );
}

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

Рис 13. Результаты применения parallax occlusion mapping'а (слева параллакс и бампмэппинг, справа - только иампмэппинг).

Использование карты расстояний до поверхности

Существует еще один интересный способ для определения видимой точки с учетом карты высот. Он описан в главе 8 книги GPU Gems 2 (данная глава может быть скачана с сайта компании NVIDIA).

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

Эта текстура строится по карте высот, где в качестве третьего измерения выступает высота. При этом значением каждого тексела является ближайшее расстояние до поверхности, задаваемой картой высот (см рис. 15).

Рис 14. Функция расстояния до поверхности.

Рис 15. Вспомогательная трехмерная текстура.

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

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

Рис 16. Последовательное приближение к точному решению.

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

Однако важным моментом является правильное согласование размеров вспомогательной текстуры со значениями внутри нее.

К сожалению данным метод требует специальным образом созданной 3D-текстуры и целого ряда зависимых (dependent) чтений из текстуры, что весьма отрицательно сказывается на быстродействии.

Cone Step Mapping, Relaxed Cone Step Mapping

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

Рис 17. Конусы на поверхности.

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

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

Рис 18. Построение приближений к точке пересечения.

К сожалению классический метод cone step mapping отличается довольно медленной сходимостью, в силу чего существует его модификация, смягчающая ограничения на ширину конуса - луч может пересечь поверхность, но не более одного раза (см. рис 19). Таким образом луч может пройти сквозь поверхность, но это сразу же определяется.

Рис 19. Построение приближений к точке пересечения. Красным обозначен классический вариант, зеленым - relaxed.

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

Рис 20. Нахождение пересечения.

Таким образом полседний вариант (relaxed cone step mapping) отличается более высокой скоростью сходимости и, как и метод с функций расстояния, гарантирует, что даже мелкие детали не будут пропущены (что может иметь место при проверке отдельных слоев). Следующий рисунок иллюстрирует каким образом находится пересечение луча и конуса.

Рис 21. Нахождение пересечения луча и конуса.

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

for ti in image:                  # for each source texel
   r[ti] = 1                      # set radius to one
   src = (ti.s, ti.t, 0)
   for tj in image:               # for each destination texel
      dst = (tj.s,tj.t,tj.depth)
      ray = Ray ( src, dst - src )
      k,w = nextIntersection ( ray )
      d   = depth ( k, w )
      if d - ti.depth > 0.0: # dst above src:
         coneRatio[ti] = len (src.xy - dst.xy) / (d - tj.depth)
         r[ti] = max ( r [ti], coneRatio [ti] )

По этой ссылке можно скачать весь исходный код к этой статье. Также доступны для скачивания откомпилированные версии для M$ Windows, Linux и Mac OS X.

Valid HTML 4.01 Transitional

Напиши мне
Используются технологии uCoz