1. Введение
2. Плоская поверхность воды
2.1 Рельефное текстурирование
2.2 Добавление локальных отражений и преломлений
2.3 Комбинирование отражений и преломлений
2.4 Модель освещения
3. Быстрое преобразование Фурье на GPU
3.1 Дискретное преобразование Фурье
4.2 Быстрое преобразование Фурье
4.3 Реализация с использованием шейдеров
4.4 Производительность
4. Статистическая модель синтеза поверхности океана
4.1 Основные положения. Примеры использования
4.2 Спектр Филлипса
4.3 Нормали и “острые” волны
5. Визуализация поверхности океана
5.1 Проекционная сетка или сетка привязанная к камере
5.2 Пересечение с пирамидой видимости
5.3 Распределение детализации
5.4 Коррекция по краям
5.5 Фильтрация
5.6 Отражения и преломления
5.7 Океанский шейдер
5.8 Производительность и оптимизация
6. Заключение
7. Источники
8. Исходный код
Реалистичная визуализация водной поверхности один из самых эффективных способов сделать 3D приложение привлекательным. Но многие алгоритмы синтеза поверхности, как правило, сложны в реализации и требовательны к аппаратуре, поэтому к вопросу выбора алгоритма стоит подойти с особым вниманием.
Ранние методы визуализации воды в реальном времени, основывались на предположении о том, что водная поверхность плоская. Иллюзия волн создавалась за счет рельефного текстурирования с использованием заранее сгенерированных карт для нормалей и высот. Такой подход до сих пор часто используется. Он отлично подходит для визуализации небольших и спокойных поверхностей воды, например озер, прудов и луж. Кроме того, плоская поверхность воды сильно упрощает визуализацию отражений, преломлений, физическую модель, требует минимального колличества полигонов и позволяет легко добавлять спецэффекты, например, волны от объектов.
К сожалению такой подход очень плохо справляется с визуализаций больших открытых водоемов, а именно тех, где ожидается увидить хоть сколько-нибудь крупные волны. Как только у 3D ускорителей появилась возможность изменять геометрию на лету, без серьезного падения производительности, рельефное текстурирование было вытеснено анимацией вершин. Но получаемые поверхности все равно приходится делать достаточно плоскими, потому что для реалистичного синтеза больших волн требуются более изощренные подходы, нежели анимация за счет заранее сгенерированных карт нормалей и высот. Таким подходом, например, является параметрическая модель “Gernster Waves”, но хотя модель применялась на практике, широкого распространения не получила. Существует гораздо более реалистичная статистическая модель, которой будет посвящена основная часть статьи.

Самым простым методом визуализации водной поверхности, как уже было сказано, является рельефное текстурирование плоскости на основе заранее сгенерированных карт нормалей или высот. Обычно используется периодическая текстура с фрактальным шумом, возможно использование объемной текстуры.
При отрисовке текстурные координаты зависят от положения точки поверхности и от времени. В качетсве функции иногда используют турбулентный шум, но использование более простых подходов также дает приемлемый результат. Вычисленная нормаль используется как текстурная координата для выборки из кубической текстуры окружения и для подсчета освещения.
![]() |
![]() |
|
| Формула 1. Функция турбулентности. P – позиция точки, t - время. Источник | Рисунок 2. Статическая карта нормалей, используемая для анимации водной поверхности |
В качестве кубической текстуры для задания отражений обычно используют только текстуру окружения. Рисование всей сцены в кубическую текстуру для создания динамических отражений невозможно, потому что кубом придется объять слишком большую область, занимаемую водоемом. Использование техники для визуализации отражений от плоского зеркала с использованием буфера маски тоже не годится, потому что с нельзя наложить рябь или каким-либо другим образом деформировать полученное отражение.
Обычно используют следующий алгоритм:


glLoadIdentity ();
glTranslatef ( 0.5, 0.5, 0 ); // из квадрата [-1,1]x[-1,1]
glScalef ( 0.5, 0.5, 1 ); // в квадрат [0,1]x[0,1]
glMultMatrixf ( projMat ); // перспективная матрица камеры отражений
glMultMatrixf ( modelViewMat ); // видовая матрица камеры отражений
Разумеется, это всего лишь пример вычисления матрицы, сам по себе этот код не даст требуемый результат.
// Определение текстурных координат через матрицу отражений.
vec4 projTexCoord = reflectionMatrix * waterVertex;
// Размытие в плоскости поверхности воды
projTexCoord.xy += distortConstant * normal.xy;
vec4 texel = texture2DProj(reflectMap, projTexCoord);
// Размытие в экранной плоскости
vec2 texCoord = projTexCoord.xy / projTexCoord.w + distortConstant * normal.xy;
vec4 texel = texture2D(reflectMap, texCoord);
Замечение: Учитывая специфику модельно-видового преобразования, совпадение матриц проекции и то, что водная
поверхность представляет собой плоскость, текстурную координату для выборки из текстуры отражений можно
определить и не вводя дополнительной матрицы. По сути в качестве текстурной координаты просто используется
относительная позиция пикселя на экране. Матрицу для отражений будем получать следующим образом:
// отражение точки относительно плоскости:
vec3 reflect(vec3 vec, vec4 plane) {
return vec - 2 * (dot(vec, plane.xyz) - plane.w) * plane.xyz;
}
vec3 position = reflect( masterCamera->getPosition().xyz(), waterPlane );
vec3 direction = reflect( masterCamera->getDirection(), vec4(waterPlane.xyz, 0.0) );
vec3 up = reflect( masterCamera->getUp(), vec4(waterPlane.xyz, 0.0) );
// look_at - создает матрицу такую же как и gluLookAt.
reflectCamera.setViewMatrix( look_at(position, position + direction, up) );
Тогда текстурная координата в карте отражений будет находиться следующим образом(GLSL):
vec4 projTexCoord = gl_ModelViewProjectionMatrix * waterVertex;
// После перспективного деления получаются нормализованные координаты,
// они в квадрате [-1,1]x[-1,1]. Их надо перевести в текстурные,
// которые в квадрате [0,1]x[0,1]. В этом и суть операций glTranslatef, glScalef
// для получения описанной матрицы для получения текстурных координат отражений.
vec2 texCoord = 0.5 * projTexCoord.xy / projTexCoord.w + vec2(0.5);
// В описанном алгоритме получения матрицы для камеры отражения инвертируется
// ось камеры OY (или up, это и нужно), но вместе с ней и OX (а это уже не нужно),
// поэтому надо инвертировать координату x.
texCoord.x = 1.0 - texCoord.x;

Точно таким же образом можно получить и карту для преломлений. Нужно только использовать не отраженную камеру, а обыкновенную и задавать плоскость отсечения таким образом, чтобы отсечь все объекты выше поверхности воды. В конце концов можно рисовать всю сцену в текстуру, как скорей всего и будет делаться, чтобы добавить постэффекты, и использовать её в качестве карты преломлений.
Использование текстуры локальных отражений позволяет визуализировать отражения от объектов любых размеров вне зависимости от их расположения в достаточно хорошем качестве, но у этого метода так же есть и серьезный недостаток.
Алгоритм не позволяет физически точно определять куда попадет луч после отражения от водной поверхности. Для наложения ряби, текстурные координаты для выборок из текстуры отражений просто сдвигают на величину проекции нормали на плоскость поверхности воды или на величину какой-либо шумовой функции. Отличить отражения, "размытые" таким образом, от настоящих крайне трудно, но сама водная поверхность выглядит очень плоско. К счастью, этот недостаток можно скомпенсировать. Для этого делаются две выборки: из текстуры отражений и из текстуры окружения. Если первая выборка “пустая”, то нет объекта для локального отражения, - в качестве цвета берётся вторая выборка, из кубической текстуры окружения. На GLSL это может выглядеть более понятно:
vec4 localRefl = texture2D(reflectMap, reflectTexCoord.xy); // Выборка из текстуры локальных отражений vec3 gobalRefl = texCUBE(skybox, reflectVec); // Выборка из текстуры окружения vec3 color = mix(gobalRefl, localRefl.rgb, localRefl.a); // Линейная интерполяция между цветами
Стоит не забывать, что при использовании этого комбинированного подхода, во время рендеринга в текстуру отражений, надо отключить отрисовку скайбокса или других объектов глобального окружения. В противном случае теряется весь смысл совмещения глобальных и локальных отражений, потому что все они будут интерпретирваться как локальные.
![]() |
![]() |
|
Для определения доли отраженной и преломленной энергии служат коэффициенты Френеля. Они определяют какая часть энергии светового пучка отразилось от поверхности в точке раздела двух сред, а какая преломилась.
![]() |
![]() |
![]() |
| Формула 2. Коэффициенты Френеля. ηi,ηt - коэффициенты преломления сред. θ - угол между нормалью и падающим лучом. |
Обычно точная формула не используется, ввиду её вычислительной сложности. Есть множество её приближений, используемых для графических вычислений в реальном времени.
![]() |
![]() |
![]() |
![]() |
![]() |
| Таблица 1. Различные формулы для приближенного вычисления коэффициентов Френеля. θ - угол между нормалью и падающим лучом. |
Используя эти коэффициенты можно вычислить финальный цвет воды в точке поверхности, комбинируя цвета отражения и преломления(или же цвет воды).
Для расчета освещения водной поверхности хорошо подходит модель Блинна-Фонга. При её использовании на поверхности возникают солнечные дорожки, а не овальные блики как при использовании модели Фонга.
Параметры для освещения обычно подбираются вручную в зависимости от конкретной сцены. Подбор этих параметров на основе характерных для водоемов физических величин практически невозможен из-за сложности физических явлений сопряженных с освещением воды. К примеру, модель освещения изменяется на протяжении суток, зависит от химического состава воды и погодных условий.
На практике, часто строят модель освещения исходя из следующих явлений:


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


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


Если вычислять преобразование Фурье напрямую, потребуется O(N2) операций. При реализации статистической модели синтеза поверхности океана будут использоваться комплексные сетки размера N=128x128(N2 = 268 435 456) и больше, - такая сложность не приемлема для вычислений в реальном времени.
Алгоритм быстрого преобразование Фурье(Cooley and Tukey “Decimation In Time”) – далее БПФ, основывается на стратегии “разделяй и властвуй”. Он не самый эффективный по количеству операций, но легко распараллеливается и не требует сложных вспомогательных вычислений, реализация которых на GPU может оказаться затруднительной или даже невозможной.
Рассмотрим первый шаг алгоритма. Просуммируем отдельно слагаемые с четными и нечетными степенями весовых коэффициентов, полагая что N делится на два:

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


Рассмотрим ещё несколько вспомогательных свойств весовых коэффициентов:



Используя последние равенствa несложно свести задачу ДПФ от N коэф-фициентов к ДПФ от N/2 коэффициентов:

Таким образом, вычисляя F(k) только для k=0..N/2 мы можем заполнить весь массив коэффициентов, уменьшив тем самым сложность вдвое.
Оба получившихся ДПФ можно разбить ещё на два, если N кратно четырем. Если N является степенью двойки, то можно свести искомое преобразование Фурье к ДПФ от одного элемента. Сложность БПФ в таком случае будет Θ(N log N).
Все вышеперечисленные рассуждения легко перенести на двумерный случай. Но лучше воспользоваться свойством многомерного преобразования Фурье: для вычисления многомерного ДПФ можно последовательно применять одномерные преобразования в каждом направлении. То есть, для двумерного случая сначала надо произвести преобразование Фурье для всех строк, потом для всех столбцов. Такой подход существенно уменьшит вычислительную сложность.
Наивное исполнение алгоритма из предыдущего раздела предполагает рекурсивное вычисление ДПФ меньшего порядка. Это не самый эффективный способ, кроме того на GPU нет стека, а следовательно нет возможности выполнять какие-либо рекурсивные алгоритмы.
Вычисление БПФ без рекурсии похоже на алгоритм сортировки слияниями. Используется два массива: один для хранения исходных данных, другой для результата. На каждом шаге, производим операцию над первым массивом и записываем результат во второй, потом меняем их местами и переходим к следующей итерации.
На нулевом шаге алгоритма в исходном массиве N коэффициентов ДПФ, фактически N преобразований Фурье размера 1. На первом необходимо посчитать N/2 преобразований Фурье размера 2, на втором N/4 размера 4 и так далее. Итого, на i’м шаге алгоритм работает N/2i разбиениями исходной последовательности длины 2i.
Для слияния двух ДПФ в одно, более высокого порядка, можно воспользо-ваться формулой 4, но необходимо ещё учитывать, что на каждом следующем шаге алгоритма будет меняться расположение коэффициентов преобразования. Классический метод индексации для БПФ называется bit-reversing. Суть его состоит в предварительной перестановке исходных элементов массива так, чтобы на каждой итерации коэффициенты для любого ДПФ были расположены последовательно.
Для реализации на GPU лучше избавиться от предварительной перестановки, используемой в этом методе. Есть способ определения индексов для итеративного вычисления БПФ без каких либо модификаций исходного массива.

Выше на рисунке изображено последовательное разбиение исходного массива: синяя группа разбивается на синюю и желтую, полученная синяя на синюю и красную, а желтая на желтую и зеленую и т. д. Если же идти снизу вверх, объединяя в группы ячейки простым суммированием, обратно тому как производилось их разбиение, то на любом уровне в каждой ячейке будет хранится сумма тех элементов, соответствующие ячейки которых имеют такой же цвет. Таким образом, сумма значений 2 и 3 ячейки на втором уровне – есть сумма всех элементов массива. На самом верхнем уровне, в каждой ячейке хранится сумма всех элементов массива.
Теперь необходимо, чтобы объединение ячеек производило объединение ДПФ. Это возможно сделать, если при суммировании на каждом этапе домножать слагаемые на необходимые весовые коэффициенты. Формула 5, как и формула 4, объединяет два преобразования Фурье, полученных на предыдущем этапе, но уже с учетом индексации в массиве.

Строгое обоснование приведенной выше формулы и более подробное описание метода описано в статье “The FFT on a GPU” Kenneth Moreland & Edward Angel. Ниже приведен формальный пример вычисления второго элемента ДПФ, с использованием этой формулы, для N=8:

Алгоритм для вычисления двумерного БПФ средствами графического API выглядит следующим образом:
renderTarget->SetDrawBuffer(0, pingPongTexture[0]);
renderTarget->SetDrawBuffer(1, pingPongTexture[1]);
BindRenderTarget(renderTarget);
int numIterations = (int)(log( (double)N ) / log(2.0) + 0.5); // log2(N)
fftProgram->Bind(); // Шейдер, выполняющий формулу 5
sizeUniform->Set(N); // Размер преобразования
// БПФ по строкам
int curPing = 0;
directionUniform->Set(1.0f, 0.0f); // Направление БПФ
for(int i = 1; i <= numIterations; ++i)
{
inputUniform->Set(0, pingPongTexture[curPing]); // Входной массив
numIterationUniform->Set(i); // Номер итерации
curPing = !curPing;
renderTarget->SetDrawBuffer(curPing); // Меняем выходную текстуру
gridQuad->Draw(QUADS);
}
// БПФ по столбцам
directionUniform->Set(0.0f, 1.0f); // Установить направление по столбцам
for(int i = 1; i <= numIterations; ++i)
{
inputUniform->Set(0, pingPongTexture[curPing]);
numIterationUniform ->Set(i);
curPing = !curPing;
renderTarget->SetDrawBuffer(curPing);
gridQuad->Draw(QUADS);
}
Шейдеры, выполняющие преобразование Фурье(выполняется два двумерных преобразования Фурье, записанных в одной текстуре формата RGBA32F или RGBA16F - каждый элемент занимает два числа, т.к. элементы комплексные):
/=============== Vertex Shader ===============/
#extension GL_EXT_gpu_shader4 : enable // используем, если есть
uniform vec2 viewport;
uniform vec2 direction; // направление: столбцы или строки
uniform float N; // размер преобразования(одномерного)
#if GL_EXT_gpu_shader4
noperspective varying vec2 index;
noperspective varying float linearIndex;
#else
varying vec2 index; // двумерный индекс
varying float linearIndex; // одномерный индекс
#endif
void main()
{
vec2 gridVertex = N * gl_Vertex.xy;
linearIndex = gridVertex.x;
index = gridVertex.x * direction.xy + gridVertex.y * direction.yx;
gl_Position = vec4( 2.0 * ( index.xy / N - vec2(0.5) ), 0.0, 1.0 );
}
/=============== Fragment Shader ===============/
#extension GL_EXT_gpu_shader4 : enable
uniform sampler2D fftInput;
uniform vec2 direction;
uniform float N;
uniform float numPartitions; // колличество выполняемых ДПФ. Равно N / 2^i, i - номер прохода
#if GL_EXT_gpu_shader4
noperspective varying vec2 index;
noperspective varying float linearIndex;
#else
varying vec2 index;
varying float linearIndex;
#endif
#define PI 3.141592653
#define PI_2 (2.0 * PI)
// Просто умножение комплексных чисел
vec2 complex_mult(vec2 a, vec2 b)
{
return vec2(a.x*b.x - a.y*b.y, a.x*b.y + a.y*b.x);
}
// Рассчет весового коэффициента
vec2 W(float partitionSize, float X)
{
float angle = PI_2 * X / partitionSize;
return vec2( cos(angle), sin(angle) );
}
void main()
{
float k = floor(linearIndex / numPartitions);
// fftIndex = ( iIndex + k * n ) % N
vec2 fftIndex = index + direction * k * numPartitions;
fftIndex -= floor(fftIndex / N) * N;
// Выбираем коэффициенты из текстуры
#if GL_EXT_gpu_shader4
vec4 He = texelFetch(fftInput, ivec2(fftIndex), 0); // Выборка без фильтрации
vec4 Ho = texelFetch(fftInput, ivec2(fftIndex + direction * numPartitions), 0);
#else
vec4 He = texture2DLod(fftInput, fftIndex / N, 0.0); // Выборка без фильтрации
vec4 Ho = texture2DLod(fftInput, (fftIndex + direction * numPartitions) / N, 0.0);
#endif
// Преобразуем коэффициенты по формуле 5
vec2 Wn = W(N / numPartitions, k);
vec4 WHo = vec4( complex_mult(Wn, Ho.xy), complex_mult(Wn, Ho.zw) ); // vec4(H*W, H*W)
vec4 result = He + WHo;
/* Сдвигаем все преобразование на некоторую величину.
* Данный фрагмент понадобится при реализации синтеза водной поверхности.
* Просто для вычисления ДПФ он не нужен.
if ( numPartitions == 1.0 )
{
Wn = W(N, -linearIndex * N / 2.0);
result.xy = complex_mult(Wn, result.xy);
result.zw = complex_mult(Wn, result.zw);
}*/
gl_FragColor = result; // H = He + W * Ho
}
В следующей таблице приведено сравнение производительности различных реализаций БПФ на GeForce 8800 GTX(Cory Quammen April 11, 2007). Рассмотренная реализация была протестирована на GeForce 8800 GT, при этом она производила два БПФ параллельно, а затраченное время было разделено на два. Видеокарты примерно одного уровня, поэтому результаты включены в ту же таблицу.
| 1D 16,384 milliseconds |
1D 8,388,608 milliseconds |
2D 10242 milliseconds |
2D 20482 milliseconds |
|
| GPUFFTW | 169 | 198 | 200 | 288 |
| CUDA | 0.701 | ? | 6.781 | 32 |
| Mitchell2003 | ? | ? | 12.205 | ? |
| Moreland2003 | Cg error | Cg error | Cg error | Cg error |
| Sumanaweera2005 | pbuffer error | pbuffer error | pbuffer error | pbuffer error |
| Рассмотренная реализация | ? | ? | 12 | 47 |
Хотя и реализация Moreland2003 оказалась не рабочей в тестовой конфигурации(по крайней мере, если верить Cory Quammen), можно предположить что производительность будет практически идентичной рассмотренной, потому что реализация крайне похожая.
Стоит обратить внимание, что узким местом является пропускная способность памяти. При изменении формата текстуры для вычислений с GL_RGBA32F на GL_RGBA16F скорость работы увеличивается практически ровно в два раза. А использование же возможностей GL_EXT_gpu_shader4 не дает никаких преимуществ по производительности.
Исходя из этого, напрашивается оптимизация: переупорядочить выполнение операций таким образом, чтобы повторяющиеся выборки из текстуры находились в кэше. Из [5] видно, что например, при n=0 и n=N/2i выборки будут повторяться. На самом деле ровно половина выборок из текстуры актуальна.
Вероятно, такая оптимизация была сделана в реализации на CUDA, что и привело к двукратному увеличению производительности, но без CUDA такую оптимизацию провести не так просто. Кроме того, при небольших размерах сетки(128x128, 256x256), которые обычно используются при визуализации водной поверхности в реальном времени, вычисление БПФ занимает совсем немного времени.
В основе статистической модели синтеза поверхности океана лежит дискретное преобразование Фурье. Волна представляется как сумма большого числа гармоник (т.е. фактически осуществлялось разложение в ряд Фурье по пространственным переменным x и y - [6]).
Проводя ДПФ над комплексной сеткой можно получить значения амплитуд волн в её узлах, то есть, карту высот. Полученная с помощью преобразования Фурье карта высот периодична и её можно “натягивать” на сколь угодно большую поверхность. Соответственно, чем больше размер сетки для преобразования Фурье, тем больше полученная карта высот, тем более изощренная поверхность и менее заметна периодичность волн, что сильно увеличивает реалистичность.



Впервые модель была описана Джерри Тессендорфом (Jerry Tessendorf. “Simulating Ocean Water”) и использовалась впоследствии почти во всех проектах, требовавших визуализации реалистичной поверхности воды. Один из наиболее полных обзоров реализации этой модели и разнообразных эффектов, таких как каустики и пена, был произведен в работе Lasse Staff Jensen and Robert Golias. “Deep-Water Animation and Rendering”.
Выше был описан алгоритм вычисления ДПФ полностью на GPU, поэтому реализацию описанной модели можно так же переложить на графический ускоритель. Первая реализация, полностью производимая на GPU, была предложена в 2005 году: Jason L. Mitchell. “Real-Time Synthesis and Rendering of Ocean Water”. Но в тот момент вычислительная мощность 3D ускорителей была ещё недостаточно велика, чтобы модель начала широко применяться в приложениях реального времени. На самом деле, таких приложений и сейчас немного.

Чтобы синтезировать реалистичную водную поверхность необходимо подобрать способ выбора коэффициенты для преобразования Фурье. Также необходимо решить вопрос о плавной анимации водной поверхности со временем.
Обычно коэффициенты ДПФ выбираются исходя из спектра Филлипса - [7]. В работе Тессендорфа есть и другие примеры спектров.




Замечание: Жирным выделены векторы, обычным шрифтом – скалярные величины либо длинны соответствующих векторов, символы с крышечкой – нормализованные векторы. Такая мнемоника будет использована и в дальнейшем.
Синтезировать спектр Филлипса каждый кадр не нужно. Можно просто ”анимировать” спектр с фиксированным по времени периодом.


Замечание:
- сопряжение
.
– исходные коэффициенты для [6].
Можно заметить, что коэффициенты преобразования Фурье комплексные, хотя высота волны представляется вещественным числом. На самом деле, комплексная компонента не лишняя, она означает фазу волны. Формула [6] как раз и описывает сдвиг фазы волны. Таким образом, сдвиг фаз всех гармоник дает плавную, и в то же время, непредсказуемую, анимацию поверхности.
Для вычисления нормалей можно воспользоваться разностью значений амплитуд в соседних узлах преобразования Фурье (соседних точек в карте высот). Но согласно Тессендорфу этот метод может давать недостаточно точные результаты при небольшой высоте волн, поэтому лучше воспользоваться ещё одним преобразованием Фурье для вычисления градиента, которое получается простым почленным дифференцированием ряда Фурье для амплитуд волн.

Замечание: Для восстановление нормали по градиенту в данном случае можно воспользоваться формулой:
normal = normalize( vec3(-slope.x, 1.0, -slope.y) ). Выводится из утверждения о том, что нормаль в
точке (x,y,z) для функции F(x,y,z) = 0 совпадает с градиентом в этой точке.
Из-за того, что преобразование Фурье представляет собой сумму гармоник, то есть достаточно гладких функций sin, cos, получить остроконечные штормовые волны трудно. Для этого придется использовать очень большие размеры сеток коэффициентов для преобразования Фурье, то есть очень большое число гармоник. К счастью есть "искусственный" способ придать волнам остроконечный профиль.
Суть метода состоит в сдвиге точек поверхности воды так, чтобы “сжать” волны. Величину сдвига можно определить из следующего преобразования Фурье, которое очень напоминает ДПФ для вычисления градиента.

Замечание:
– нормализованный вектор k.
При этом использование ДПФ для вычисления градиента и нормалей уже не годится. Но большого смысла оно и не имеет, потому что остроконечные волны бывают только при достаточно сильном ветре. В таких условиях волны крупнее и вычисление нормали через разницу амплитуд в соседних точках дает достаточно точный результат. Это обстоятельство довольно удобно, потому что и при слабом и при сильном ветре разумно вычислять только два преобразования Фурье, которые укладываются в одну четырехкомпонентную текстуру.
Визуализация огромной поверхности океана достаточно ресурсоемкая задача. Во-первых, из-за большого числа видимых пикселей и сложности фрагментного шейдера, используемого при отрисовке. Во-вторых, из-за выборок из текстуры в вершинном шейдере. Соответственно первая проблема остро встает при больших разрешениях, а вторая для более старых видеокарт.
Со второй проблемой бороться за счет реалищации грамотной системы распределения уровня деталлизации и использования как можно меньшего колличества вершин.
Одним из самых оптимальных способов уменьшения уровня детализации в зависимости от расстояния до наблюдателя является использование проекционной сетки.

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

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

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

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


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

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

Замечание: На рисунке приведен пример с чрезмерно “растянутой” картой высот. В реальности такое соотношение разрешения текстуры и размера фрагмента поверхности использовать не стоит.
Некоторые решения данной проблемы:
Стоит заметить, что после того как преобразование Фурье произведено, карту высот и нормалей можно перерисовать в обычные текстуры и успользовать уже их для визуализации поверхности. Это снизит суммарное колличество выбираемой памяти. Видеокарты уровная Direct3D 10 могу использовать любые форматы текстур в вершинном шейдере. Выборки же из карты нормалей можно делать в фрагментном шейдере.
Деформированная поверхность воды усложняет использование описанных алгоритмов для генерации отражений и преломлений, потому что возникает проблема определения фрагментов попавших под воду(на воду), которые не нужно рисовать в карту отражений (соответсвенно преломлений), из-за этого могут быть видны артефакты стыковки отражений(преломлений) и видимых объектов в точке их соприкосновения с водой.

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

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

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

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

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

Далее рассмотрен шейдер для визуализации океана, работающий при следующих условиях, по крайней мере иногда:
/=============== 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
}



При визуализации океанской поверхности, самая медленная операция - вывод проекционной сетки(конечно, если не производить БПФ огромного размера). И хотя пиксельный шейдер для воды с преломлениями и отражениями не самый простой, основной удар по производительности наносит именно выборки из текстуры в вершинном шейдере. Поэтому, имеет смысл оптимизировать вывод сетки именно для вершинного процессора. Разумеется, от выборок из текстуры никак не избавиться, но можно попробовать наиболее эффективно использовать вершинный кэш, в котором хранятся вершины, прошедшие обработку в вершинном шейдере(Post-TnL cache).
Рассмотрим пару возможных топологий проекционной сетки:


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


Утверждается, что Post-TnL кэш работает по принципу FIFO: проверяем, есть ли вершина в кэше, если нет - добавляем её туда и удаляем самую старую вершину. Автору это доподлинно неизвестно, но с высокой долей вероятности можно утверждать, что он кэш вытесняет самые старые или самые редкоиспользуемые вершины. Учитывая это, можно составить топологию таким образом, чтобы из кэша не успевали удалиться вершины, которые будут использоваться при построении очередного треугольника. Например, можно построить следующую топологию:

Видно, что если используется принцип FIFO, к моменту построения каждой следующей полоски, кроме первой полоски в столбце, обработанные вершины ещё будут находиться в кэшэ, если длина полоски более чем в два раза меньше размера кэша. Яснее будет, если рассмотреть порядок добавления вершин в кэш: 0, 9, 10, 1, 11, 2, 12, 3, ... . Таким образом, после отрисовки первых двух треугольников из второй полоски из кэша удалятся вершины 0, 9, 10, 1 которые уже не понадобятся.
Можно пойти дальше. Видно, что в кэше идут вершины из двух рядов вперемежку, хотя при отрисовке полоски нужен только один ряд. Было бы замечательно, если бы вершины добавлялись в кэш следующим образом: 0, 1, 2, 3, 9, 10, 11, ... . Тогда длину полоски можно было бы увеличить в два раза. Сделать это можно, если использовать фиктивную полоску треугольников, составленую из вершин первого ряда, например: (0 1 2) (3 4 5), а потом уже добавлять "нормальные" полоски. Разумеется, хотя везде и говорится про полоску треугольников, это не значит что необходимо использовать GL_TRIANGLES или аналог, можно создавать такую топологию, используя GL_QUADS, GL_QUAD_STRIP, GL_TRIANGLE_STRIP, более подробно про организацию полосок и кэш можно прочитать тут.
Так же как и для предыдущих топологий на следующем рисунке визуализированы редполагаемые кэш промахи при иcпользовании оптимизированной топологии.

Должен возникнуть вопрос: а какая максимальная длина используемых полосок или какой размер Post-TnL кэша? К сожалению, автору неизвестен ответ на вопрос. На следующем графике изображена зависимость времени отрисовки проекционной сетки от длины полоски, по нему, в частности, можно приблизительно определить размер кэша для тестируемой видеокарты: когда полоски уже не влезают в кэш, производительность должна резко упасть. На самом деле, судя по графику, она упала не резко, и не только эта особенность кажется странной. Единственное разумное объяснение, что драйвер каким-то образом сам переставляет треугольники для лучшего кэширования, но судя по всему, не всегда удачно это делает :).

Как видно по графику, оптимизация топологии дает очень хороший результат, ускоряя рендер проекционной сетки более чем на треть. Тем не менее, отрисовка геометрически анимированной с помощью выборок в вершинном шейдере океанской поверхности едва ли приемлемая операция для старых видеокарт. Далее представлен график с замерами производительности тестового приложения на некоторых конфигурациях:
| 800x600, proj grid 150x600, FFT 128x128 | 800x600, proj grid 150x600, FFT 256x256 | 800x600, proj grid 150x600, FFT 512x512 | 800x600, proj grid 150x600, FFT 1024x1024 | 1280x1024, proj grid 240x960, FFT 128x128 | 1280x1024, proj grid 240x960, FFT 256x256 | 1280x1024, proj grid 240x960, FFT 512x512 | 1280x1024, proj grid 240x960, FFT 1024x1024 | |
| GeForce 7300Go | 17 fps | 12 fps | 5.3 fps | 1.26 fps | 6.7 fps | 5.7 fps | 3.57 fps | 1.13 fps |
| GeForce 7600GT | 85 fps | 54 fps | 21 fps | 5.8 fps | 37 fps | 29 fps | 16 fps | 5.3 fps |
| Radeon 3870HD | 290 fps | 275 fps | 133 fps | 39 fps | 170 fps | 143 fps | 93 fps | 34 fps |
| Radeon 4850HD | 370 fps | 360 fps | 161 fps | 47 fps | 270 fps | 220 fps | 126 fps | 43 fps |
В статье были рассмотрены лишь некоторые алгоритмы синтеза водной поверхности из их великого множества. У всех этих алгоритмов совершенно разные достоинства и недостатки, а соответсвенно и сферы применения.
Например, совершенно не имеет смысла реализовывать статистическую модель, хотя она и самая продвинутая, если в сцене присутсвуют только маленькие водоемы: озера, лужи, кружки с чаем. Поверхность, синтезируемая этой моделью характерна только для морей, океанов, супер-океанов, в которых волны возникают под действием сильного ветра. По большому счету, можно руководствоваться таким принципом: чем меньше водоем, тем меньше волны, тем проще модель и меньше проблем c её встраиванием в графический движок.
Разумеется, огромное влияние оказывает и целевая аудитория. Так например, если будет использоваться алгоритм, в котором необходимо производить выборки из текстуры или же рендерить в вершинный буфер, пользователи с видеокартами уровня ниже GeForce 6 серии вообще не смогут запустить приложение. На самом деле, учитывая производительность подобной операции, класс видеокарт, которые нормально справятся с нормальным приложением, использующем её, будет ещё меньше.
Последним фактором при выборе того или иного алгоритма оказывается сложность реализации (не смотря на то, что любой руководитель проекта поставил бы его первым пунктом). Реализовать статистическую модель синтеза поверхности океана и БПФ на GPU, определенно, сложно. Есть некоторые библиотеки, которые это делают, но вполне вероятно, что их использование окажется неприемлемым. При этом сложность модели только вершина айсберга. Как было видно из статьи, крупные волны усложняют практически все алгоритмы смежные с визуализацией водной поверхности: управление уровнем детализации, добавление отражений, физика взаимодействия с водой и т. д.
К сожалению, в статье не рассмотрены алгоритмы визуализации каустиков, пены, просвечивающих сквозь толщу воды лучей солнца, но всё это можно найти в работе Lasse Staff Jensen & Robert Golias "Deep-Water Animation and Rendering". Дополнительную информацию по статистической модели можно получить из статьи Jerry Tessendorf “Simulating Ocean Water”. В работе Tarjei Kvamme Loset “Real-Time Simulation and Visualization of Large Sea Surfaces” кроме вопросов реализации статистической модели рассматиривается физическое моделирование водной поверхности как карты высот. И пожалуй, ещё стоит почитать про vertex texture fetch и R2VB, чтобы учеть ньансы и не ходить по граблям как это делал я. Пожалуй всё, удачи.
Комментарии
океан
Сейчас пытаюсь реализовать модель Тессендорфа. Делаю это для изучения в Houdini python и VEX. Дмитрий, очень хотелось бы пообщаться, есть пара вопросов по статье, обещаю не отнимать много времени (свою почту я оставил).
Да, конечно. Я отправил
Да, конечно. Я отправил письмо.
Спектр Филлипса
Дмитрий, здравствуй.
Я изучал данную статью, а также статью Тессендорфа, но я плохо понимаю генерацию спектра Филлипса. Не найдется ли программная реализация на каком-либо языке для главы "4.2 Спектр Филлипса"? Очень нужно...
Отправить комментарий