Автор: Дмитрий Трифонов «DikobrAz»

Опубликовано: 11.04.2010

Изменено: 11.04.2010

Постоянная ссылка

Комментарии [24]

Визуализация водной поверхности. Быстрое преобразование Фурье на GPU





Страницы: 1 2 3 4

5. Визуализация поверхности океана

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

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

    5.1 Проекционная сетка или сетка привязанная к камере

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


Рисунок 14. Проекционная сетка

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

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


Рисунок 15. Сетка привязанная к камере

    5.2 Пересечение с пирамидой видимости

В некоторых случаях сетка не проецируется на плоскость воды (рис. 16) или при её проецировании, полученная трапеция имеет слишком большую площадь. Второй случай плох, потому что сетка полигонов слишком растянется и будет иметь недостаточную плотность близи камеры. Будут видны артефакты.


Рисунок 16. Случай, когда нет возможности правильно спроецировать экранную плоскость на плоскость воды

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


Рисунок 17. Поворот камеры, чтобы была возможность спроецировать сетку на плоскость

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

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

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

    5.3 Распределение детализации

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

В предложенной реализации была использована следующая формула для распределения точек в проекционной сетке:



Лучше подгонять параметры α,β и размеры сетки в зависимости от разрешения и разностью между ближней и дальней плоскостью отсечения. В целом, значения α = 5.0,β = 1.0 / (eα - 1.0) при размерах сетки от 128x768 дают приемлемый результат.

    5.4 Коррекция по краям

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


Рисунок 18. Артефакты, возникающие из-за сдвига вершин сетки поверхности воды

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

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

    5.5 Фильтрация

Вычисление ДПФ можно производить только с использованием вещественной текстуры, использование других форматов не дает достаточной точности. Тут возникает проблема: видеокарты уровня Direct3D 9, если и поддерживают фильтрацию вещественных текстур, то только fp16. С другой стороны, эти же видеокарты в вершинном шейдере могут выбирать только из текстур 32 битной точности (и то, как правило, не всех).


Рисунок 19. Поверхность воды без фильтрации
Замечание: На рисунке приведен пример с чрезмерно "растянутой" картой высот. В реальности такое
соотношение разрешения текстуры и размера фрагмента поверхности использовать не стоит.

Некоторые решения данной проблемы:

  • Уменьшение соотношения: разрешение текстуры / размера фрагмента водной поверхности. Д. Тессендорф в своей работе говорит, что хорошими являются соотношения 5-10 см. реальной поверхности на тексель в карте высот.
  • Выполнение фильтрации в шейдере. Стоит учитывать, что выборка из текстуры в вершинном шейдере - дорогая операция.

Стоит заметить, что после того как преобразование Фурье произведено, карту высот и нормалей можно перерисовать в обычные текстуры и успользовать уже их для визуализации поверхности. Это снизит суммарное колличество выбираемой памяти. Видеокарты уровная Direct3D 10 могу использовать любые форматы текстур в вершинном шейдере. Выборки же из карты нормалей можно делать в фрагментном шейдере.

    5.6 Отражения и преломления

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



Рисунок 20. Проблемы стыковки видимых объектов и их отражений и преломлений

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

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


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

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



Рисунок 22. Скриншоты из демо Unigine и игры Crysis

Можно пойти и другим путем(если вам все-таки требуются большие волны и сильно искаженные отражения и преломления) - наоборот, деформировать и размыть всё сильнее, чтобы опять же, баг было труднее заметить. Конечно, оба решения косметические, но в конце концов, они дают приемлемый результат. Для пущего размытия можно вместо обычной выборки из текстуры отражений делать выборку не из старшего mipmap уровня(сделать это можно командой GLSL texture2DLod). Разумеется перед отрисовкой воды нужно сгенерировать mipmap уровни(в OpenGL функция glGenerateMipmapEXT):


Рисунок 23. Сильное искажение и размытие отражений, чтобы спрятать артефакты у места соприкосновения объектов с водой

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



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

В случае с отражениями, использовалась плоскость отсечения для отбраковки объектов, которые недолжны отражаться. Для преломлений можно предложить более точное решение. Нужно сравнивать расстоянием до объекта с расстоянием до поверхности воды. Если первое больше, то объект находится под водой и преломления учитывать нужно, в противном случае искажать преломления не надо. После такой коррекции ещё остаются проблемы как на рисунке 19 или 23 справа. Проблемы стыковки, как уже говорилось, обычно не исправляют, но ограничивают. А коррекцию по краям можно сделать уменьшая силу искажения в зависимости от расстояния до рамки окна, примерно так как это делалось в случае с амплитудой волн при использовании проекционной сетки.


Рисунок 25. Корректировка преломлений

    5.7 Шейдер для океана

Далее рассмотрен шейдер для визуализации океана, работающий при следующих условиях, по крайней мере иногда:

  • Поверхность воды лежит в плоскость XOZ, поэтому рассчет освещения производится в мировом пространстве, чтобы не заморачиваться с касательным.
  • Используется проекционная сетка, при этом пересечение с пирамидой видимости приводится к четырёхугольнику и в шейдер передаются только его граничные точки.
  • Вода визуализируется сеткой с точками в квадрате [0,1]x[0,1]. Они соответсвенно растягиваются, чтобы заполнить весь четырёхугльник, полученный проецированием граничных точек экранной плоскости на плоскость воды. Расчет этих точек производится в коде программы, а растягивание в шейдере.
  • Текстура с нормалями вычисляется отдельно, используя преобразование Фурье или же просто разницу амплитуд в соседних точках.
  • Опционально можно включить отражения и преломления. При этом текстура с отражениями должна иметь mipmap уровни. Для учета глубины следует также передать в шейдер карту глубин, используемую при рендеринге в текстуру отражений.
/=============== Vertex Shader ===============/
// Карта полученная с помощью двух FFT: для вычисления аплитды волн, для придания волнам острого профиля. 
// Первые две компоненты - (высота, фаза), вторые две - смещение для придания остроугольности.
uniform sampler2D heightMap;

uniform float sharpness;         // величина, характеризующая остроту волн
uniform vec2  surfaceSize;       // размер просчитанного сегмента поверхности 
uniform vec3  surfaceCorners[4]; // четырехугльник, полученный при пересечении с пирамидой видимости
uniform vec4  lightPosition;     // позиция источника света в мировых координатах
uniform vec4  eyePosition;       // позиция наблюдателя в мировых координатах

uniform mat4  projectionMatrix;  
uniform mat4  worldViewMatrix;
uniform mat4  reflectionMatrix;  // матрица для вычисления текстурных координат в карте отражений

varying vec2  normalTexCoord;
varying vec2  reflectionTexCoord;
varying vec3  eyeDir;
varying vec3  lightDir;
varying vec3  fragmentPosition;

void main()
{
    // Вычисляем абсолютную позицию точки внутри четырехугольника
    vec3 a = (surfaceCorners[2] - surfaceCorners[3]) * gl_Vertex.y;
    vec3 b = (surfaceCorners[1] - surfaceCorners[0]) * gl_Vertex.y;
    vec3 c = mix(b, a, gl_Vertex.x);
    vec3 d = (surfaceCorners[3] - surfaceCorners[0]) * gl_Vertex.x;
    vec4 waterVertex = vec4(surfaceCorners[0] + c + d, 1.0);
    
    // Вычисляем текстурную координату для нормали и амплитуды
    normalTexCoord = waterVertex.xz / surfaceSize;
    
    // Сдвигаем вершину, используя карту высот и смещения
    vec4  texel       = texture2D(heightMap, normalTexCoord); 
    float attenuation = min( pow(1.0 - gl_Vertex.y, 0.2), pow(1.0 - abs(gl_Vertex.x - 0.5) * 2.0, 0.2) );
    waterVertex.y     = texel.x * attenuation;
    waterVertex.xz   -= sharpness * texel.zw * attenuation;
    
    // Вектор от наблюдателя(источника света) в точку океана
    eyeDir   = waterVertex.xyz - eyePosition.xyz;
    lightDir = waterVertex.xyz - lightPosition.xyz;

    // Вычисление текстурной координаты для отражений,
    // её деформацию производится в фрагментном шейдере, чтобы
    // не делать лишних выборок в вершинном.
    vec4 projTexCoord  = reflectionMatrix * waterVertex; 
    reflectionTexCoord = projTexCoord.xy / projTexCoord.w;
    
    gl_Position      = projectionMatrix * worldViewMatrix * waterVertex;
    
    // Вычисляем нормализованную позиция фрагмента. В третьей компоненте расстояние до точки
    fragmentPosition = vec3(gl_Position.xy / gl_Position.w, gl_Position.w);       
}

/=============== Fragment Shader =============/
uniform sampler2D   depthMap;       // Текстура глубины, используемая при рендеринге преломлений
uniform sampler2D   normalMap;      // Предрассчитанная текстура с нормалями для карты высот
uniform sampler2D   reflectMap;     // Текстура с отражениями
uniform sampler2D   refractMap;     // Текстура с преломлениями
uniform samplerCube environmentMap; // Текстура окражения(skybox)

uniform float distanceAttenuation;  // Величина характеризующая скорость затуманивания в зависимоти от дальности
uniform float waterTransparency;    // Прозрачность воды
uniform vec4  fogColor;             // Цвет тумана
uniform vec4  lightSpecular;        // Увет источника света 

// Нижний правый угол инвертированной песпективной матрицы.
// Матрица нужна для восстановления расстояния до точки по значению в текстуре глдубины.
// Нужен только угол размера 2х2, потому что есть необходимость только в последних
// двух компонентах для определения расстояния до точки.
uniform mat2  projectionMatrixInverse; 

varying vec2  normalTexCoord;
varying vec2  reflectionTexCoord;
varying vec3  eyeDir;
varying vec3  lightDir;
varying vec3  fragmentPosition;

// Получить глубину точки по значению в карте глубин.
// fragmentPosition - позиция фрагмента в нормализованных экранных координатах.
// texCoord - текстурная координата для выборки из текстуры глубин.
float get_fragment_depth(sampler2D depthMap, vec2 fragmentPosition, vec2 texCoord)
{
    float depth = 2.0 * texture2D(depthMap, texCoord).r - 1.0;
    vec2  vec   = projectionMatrixInverse * vec2(depth, 1.0);
    return -vec.x / vec.y;
}

void main()
{
    const vec3 waterMinColor = vec3(0.0, 0.05, 0.15);
    const vec3 waterMaxColor = vec3(0.0, 0.1,  0.15);

    // Нормализованное направление на точку и нормаль в мировых координатах
    vec3 eyeDirNorm = normalize(eyeDir);
    vec3 normalNorm = 2.0 * ( texture2D(normalMap, normalTexCoord).rgb - vec3(0.5) );

    // Нормаль будем выпрямлять вдали, чтобы поверхность выглядела менее периодичной
    float distVal = exp(-fragmentPosition.z * distanceAttenuation);
    normalNorm    = mix( vec3(0.0, 1.0, 0.0), normalNorm, pow(distVal, 10.0) );

#ifdef ENABLE_LIGHTING
    // Рассчет освещения по модели Блинна-Фонга
    vec3 lightDirNorm = normalize(lightDir);
    vec3 halfVecNorm  = normalize(eyeDirNorm + lightDirNorm);
    vec4 light        = lightSpecular * pow( abs( dot(halfVecNorm, normalNorm) ), 16.0);

    // Ослабляем освещенность вдали, так выглядит естественней
    gl_FragColor = light * pow(distVal, 10.0);
#else
    // Меняем цвет воды в зависимоти от угла обзора.
    float dotValue   = dot(eyeDirNorm, normalNorm);
    vec3  waterColor = mix( waterMinColor, waterMaxColor, abs(dotValue) );

    // Вычисляем текстурную координату, соответсвующую позиции фрагмента
    vec2  fragTexCoord = 0.5 * fragmentPosition.xy + vec2(0.5); 
            
    #ifdef ENABLE_REFRACTIONS
    {     
        // Деформируем текстурную координату для преломлений
        vec2  distortTexCoord = fragTexCoord + normalNorm.xz * 0.02;
        
        #ifdef ENABLE_DEPTH_MAP
        float nonDistortDepth = get_fragment_depth(depthMap, fragmentPosition.xy, fragTexCoord);
        float distortDepth    = get_fragment_depth(depthMap, fragmentPosition.xy, distortTexCoord);

        // Защищаем от деформации объекты над поверхностью воды
        float deltaDepth;
        if (distortDepth > fragmentPosition.z) {
            deltaDepth = distortDepth - fragmentPosition.z;
        }
        else 
        {
            distortTexCoord = fragTexCoord;
            deltaDepth      = nonDistortDepth - fragmentPosition.z;
        }
        
        // Вычисляем ослабление в зависимости от глубины. Можно использовать и экспоненту
        float depthAttenuation = 1.0 / pow(1.0 + deltaDepth, 1.0 / waterTransparency - 1.0);

        // Смешиваем преломления с цветом воды
        vec4 refractColor = texture2D(refractMap, distortTexCoord).rgba;
        waterColor        = mix(waterColor, refractColor.rgb, depthAttenuation * refractColor.a); 
        #else
        vec4 refractColor = texture2D(refractMap, distortTexCoord).rgba;
        waterColor        = mix(waterColor, refractColor.rgb, waterTransparency * refractColor.a); 
        #endif
    }
    #endif

    // Вычисляем коэффициенты Френеля
    float fresnel      = clamp( pow(1.0 + dotValue, 4.0), 0.05, 0.3 );
    vec3  reflectColor = textureCube( environmentMap, reflect(eyeDirNorm, normalNorm) ).rgb;

    #ifdef ENABLE_REFLECTIONS
    {
        // Добавляем локальные отражения
        vec2 distortTexCoord   = reflectionTexCoord + normalNorm.xz * 0.05;
        vec4 localReflectColor = texture2DLod(reflectMap, distortTexCoord, 2.0).rgba;
        reflectColor           = mix(reflectColor, localReflectColor.rgb, localReflectColor.a);
    }
    #endif

    // Смешиваем все
    waterColor = mix(waterColor, reflectColor, fresnel);
    waterColor = mix(fogColor.rgb, waterColor, distVal);

    gl_FragColor = vec4(waterColor, 1.0);
#endif
}




Рисунок 26. Океан, отрендеренный с помощью шейдера, рассмотренного выше.
Размер преобразования Фурье 256x256


Рисунок 27. Океан отрендеренный без отражений и преломлений.
Размер преобразования Фурье 1024x1024