Главная Статьи Ссылки Скачать Скриншоты Юмор Почитать Tools Проекты Обо мне Гостевая |
Характерной чертой развития 3D-графики является все возрастающая степень детализации объектов. Первым шагом в такой детализации было применение bumpmapping-а (впервые предложенное Блинном).
Сейчас это стало повсеместно распространенным использовать bumpmapping, т.е. попиксельное задание вектора нормали, для моделирования неровностей поверхности (ее микрорельефа). За счет этого удается не усложняя геометрию объектов создать иллюзию микрорельфа, т.е. высокой степени детализации.
Рис 1. Пример использования bumpmapping-а.
Однако bumpmapping дает четко видимые результаты либо для освещения небольшим количеством точечных источников света (при этом желательно чтобы поверхности имели сильно выраженную бликовую компоненту как в DooM 3), либо при отражении (EMBM).
Случай же объекта, освещенного рассеянным светом (например объекта, находящегося под открытым небом в облачную погоду) традиционным bumpmapping -ом вообще не обрабатывается.
Однако есть и другие проявления микрорельефа поверхности - изменение текстурных координат точек в зависимости направления на наблюдателя, самозатенение и т.п.
В данной статье мы рассмотрим моделирование подобных явлений начиная с самых простых и заканчивая наиболее продвинутыми методами.
Проще всего задавать микрорельеф поверхности при помощи так называемой карты высот (height map) h(s,t). При этом возможны два варианта трактовки этой карты. Значения из нее могут рассматриваться либо как высота точки над гранью (рис 2а), либо как углубление внутрь грани (рис 2b). Мы далее будем придерживаться первой интерпретации.
Рис 2. Возможные интерпретации карты высот.
Простейшим случаем моделирования рельефа (не считая bumpmapping-а) является так называемый parallax (relief) mapping - простой способ коррекции текстурных координат с использованием карты высот.
Когда неровная поверхность (задаваемая картой высот h(s,t)) рассматривается с направления v, то если мы не учитываем микрорельефа поверхности, в качестве точки на ней будет взята точка А (точнее, соответствующие этой точке текстурные координаты). Однако точному пересечению луча от наблюдателя соответствует точка В (и соответствующая ей точка T* в текстурных координатах).
Рис. 3. Приближенное вычисление точки на поверхности.
Подобное явления - т.е. изменение текстурных координат в зависимости от направления на наблюдателя в связи с микрорельефом поверхности, и называется parallax mapping. Обратите внимание, что все вычисления проводятся в касательном пространстве грани (tangent space), поэтому важно, чтобы для каждой грани было правильно задано касательное пространство.
Можно использовать значение высоты в точке А для получения более точного значения текстурных координат (T1):
В этой формуле через 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 ); }
Рис 4. Изображение, получаемое при помощи приведенных шейдеров (слева - параллакс включен, справа - нет).
Однако данный способ коррекции текстурных координат является лишь приближенным, в частности когда вектор v почти касается поверхности (т.е. vz << 1), то использование рассмотренной ранее формулы приводит к большим погрешностям. Поэтому иногда вместо этой формулы используют следующую упрощенную формулу, вообще отказавшись от деления (parallax with offset limiting)
Кроме того в качестве высоты h обычно используется смещенное значение:
Ниже приводится изображение поверхности, полученное при помощи этого способа.
Рис 5. Parallax with offset limiting.
Однако рассмотренный выше parallax mapping дает лишь приближенное значение, годящееся для поверхностей без резких изменений высоты, в противном случае он может давать ошибочные значения. Так на рис. 3 вместо точного значения T*, соответствующего пересечению луча с поверхностью, мы берем значение T1 (хотя это лучше чем исходное значение T0).
Одним из крайне простых способов улучшения точности классического 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 предлагалось использовать вместо линейной интерполяции экспоненциальную.
Кроме того для устранения этих погрешностей можно использовать найденную путем перебора слоев точку как начальное приближения и произвести уточнение точки пересечения при помощи метода половинного деления.
Рис 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 не только для определения точной точки на поверхности, но также и для проверки ее затененности (закрывания микрорельефом от источника света).
Для этого поиск пересечения луча с поверхностью начинается от найденной точки пересечения и ведется в направлении вектора на источник света. Перебор слоев в этом случае осуществляется в обратном направлении (т.е. соответствующем увеличению высоты).
Существует еще один вариант развития 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) чтений из текстуры, что весьма отрицательно сказывается на быстродействии.
Сущесвует еще один очень изящный метод, близкий к использованию функции расстояния, но обходящийся всего 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.