Бесплатная реклама

Автор: Михаил Ильин «Mikle»

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

Изменено: 10.05.2010

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

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

Рендеринг неограниченной водной поверхности, преломления, отражения, каустики


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


Содержание

1. Предисловие
2. Общее описание
3. Результирующее изображение
4. Отражения
5. Вода
6. Подводная часть ландшафта
7. Заключение
8. Демо-программа

1. Предисловие

В статье представлен способ рендеринга неограниченной водной поверхности с преломлениями и отражениями, использующий ряд нестандартных подходов. Статья сопровождается демо программой (EXE) с исходным кодом на языке Visual Basic 6. Для работы демо требуется поддержка вертексных и пиксельный шейдеров версии 2.0 и DirectX версии не ниже DirectX 9.0c. Программа не требует установки SDK runtime, однако в комплект входит компилятор шейдеров, для его запуска потребуется SDK runtime за март 2008 г. (D3DX9_37.DLL).

В статье не делается попытка продемонстрировать «научный подход», большая часть эффектов – фейковая, но выглядят они при этом достаточно хорошо, а быстродействие позволяют повысить. Я практически не останавливался на описании стандартных технологий, например, практически не описывал рендер неба – это не относится к теме статьи. Исходники всех шейдеров, а так же компилятор для них, предоставлены, то есть вам не обязательно устанавливать (и изучать ?) Visual Basic для того, чтобы экспериментировать с шейдерами. Надеюсь, что вы почерпнёте из статьи для себя что-нибудь полезное, приятного чтения!

2. Общее описание

В программе есть три рендерящихся объекта:
1. Sky (небо). При отрисовке бесконечной воды мы никогда не увидим часть скайбокса, расположенную ниже горизонта, поэтому применение кубической карты не рационально, применена сферическая карта, «натянутая» на конус, в центре основания которого всегда расположена камера. Рендер проходит с отключенным Z-буфером дважды, первый раз обычно, только с записью 1 в альфа канал, второй раз – перевёрнутое изображение, с записью 0 в альфа канал.
2. Water (вода). Для простоты примем уровень воды (по оси Y) за 0. Однако саму воду рисуем не плоскостью, а перевернутым усечённым конусом, расположенным напротив конуса неба. Конус усечён на уровне -0.3. Как заставить его выглядеть плоскостью на уровне 0 будет рассмотрено дальше.
3. LandScape (ландшафт). Это название условно, подразумевает все предметы, которые могут находиться над или под водой, а так же пересекаться водной поверхностью. В программе это небольшой фрагмент ландшафта, созданный по карте высот (регулярной сетке).

Все объекты в программе рендерятся не в бэкбуфер, а в текстуры, из которых потом и формируется результирующее изображение. Таких текстуры четыре, запустив демо, вы можете их увидеть, нажимая клавиши «1», «2», «3», «4». Так же, нажав пробел, можно увидеть содержимое альфа-канала этих текстур.

1) Изображение надводной части. Сначала очищается Z-буфер и рендерится небо, затем устанавливается плоскость отсечения на уровень 0 по оси Y и рендерится ландшафт. Плоскость создаём так:

  PlaneFromPoints PlaneUp, Vec3(0, 0, 0), Vec3(0, 0, 1), Vec3(1, 0, 0)

Перед установкой умножаем плоскость на инвертированную матрицу WorldViewProj:

  MatrixInverse Mtrx, ByVal 0, Mtrx
  PlaneTransform p, PlaneUp, Mtrx
  Dev.SetClipPlane 0, VarPtr(p)
  Dev.SetRenderState D3DRS_CLIPPLANEENABLE, D3D_TRUE

2) Изображение отражений. Сначала очищается Z-буфер и рендерится небо, затем рендерим перевёрнутое изображение ландшафта. Для создания такого изображения достаточно инвертировать в матрице мира ось Y, у нас ландшафт изначально не трансформируется (MatrixWorld = MatrixIdentity), поэтому просто записываем:

  MatrixScaling MatrixWorld, 1, -1, 1

Для корректной работы такого преобразования нужно так же сменить режим Cull на противоположный. Обратите внимание – мы оставляем камеру на месте!

3) Изображение подводной части ландшафта. Очищаем Z-буфер и Color-буфер, в Color-буфер записываем цвет воды. Далее рисуем подводную часть ландшафта так же, как мы рисовали надводную, но переворачиваем плоскость отсечения:

  PlaneFromPoints PlaneDown, Vec3(0, 0.1, 0), Vec3(1, 0.1, 0), Vec3(0, 0.1, 1)

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

4) Изображение нормалей воды. То есть мы отображаем текстуру нормалей, как обычную текстуру. Тут можно не чистить Z- и Color-буферы, а Z-буфер вообще отключить. На той части, куда не попадает изображение воды, видны артефакты, но эта часть не участвует в формировании результирующего изображения.

3. Результирующее изображение

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

  vs_2_0

  dcl_texcoord0 v1

  mov oT0, v1

В пиксельном шейдере принимаем их и объявляем четыре семплера:

  dcl t0

  dcl_2d s0  //Отражения
  dcl_2d s1  //Верх
  dcl_2d s2  //Низ
  dcl_2d s3  //Вода

«R» и «G» компоненты текстуры воды отвечают за смещение видимого изображения при преломлениях и отражениях. Их диапазон – от 0 до 1, а нам требуется от -0.5 до 0.5, приводим их в соответствие:

  //Вода
  texld r3, t0, s3
  add r3.rg, r3, c0.b

В компоненте «b» константы c0 записано значение -0.5. Для формирования отражений необходимо наложить текстуру отражений с поправками, вносимыми водой. В «R» и «G» компоненты константы c0 запишем значение -0.35, это будет коэффициент искажений при отражении, он взят со знаком минус для того, чтобы отражения и преломления искажались в разные стороны (в преломлениях будет положительный коэффициент):

  //Отражения
  mad r4.rg, r3, c0, t0
  texld r0, r4, s0

Надводную часть отображаем обычным способом:

  //Верх
  texld r1, t0, s1

Теперь подводная часть. Посмотрите на реальную воду – величина искажений изображения подводных объектов зависит от глубины, предметы у поверхности почти не искажаются. Чем глубже предмет, тем сильнее искажения, вносимые преломлением. То есть желательно иметь переменный коэффициент преломлений. И такой коэффициент можно вычислить при рендере подводной части ландшафта и записать в альфа компоненту полученной текстуры. Как он вычислен – рассмотрим позже, пока продолжим рассматривать шейдер, формирующий результирующее изображение (см. World.psh в редакторе шейдеров). Читаем текстуру подводной части, берём из неё альфу и умножаем величину преломлений (r3.rg) на эту величину. Затем добавляем к текстурным координатам полученное смещение и повторно читаем текстуру подводной части уже с новых координат:

  //Низ
  texld r5, t0, s2
  mul r3.rg, r3, r5.a
  add r4, r3, t0
  texld r2, r4, s2

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

В оптике существует формула Френеля, согласно которой свет, проходя границу двух сред с различными показателями преломлений, частично отражается от этой границы, и частично сквозь неё проходит, луч света (A) расщепляется на два луча (B и C). Отражается свет под тем же углом, под которым упал (b = a), но угол проходящего луча изменяется (c <> a), он преломляется. Так же, в зависимости от угла падения, меняется и интенсивность отражённого и преломленного лучей.

Рендеринг воды - формула Френеля

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

В данной демо программе при уменьшении угла зрения от 0° (вертикально вниз) до некоторой величины коэффициент отражений плавно растёт до единицы, а коэффициент преломлений соответственно уменьшается (сумма коэффициентов равна единице). Далее до 90° остаются только отражения без преломлений. Такой коэффициент у нас сформирован в альфа канале изображения воды (формирование рассмотрим дальше). Смешиваем преломления и отражения согласно этому коэффициенту:

  //Микс преломлений и отражений
  lrp r0, r3.a, r0, r2

Остаётся смешать полученное изображение с изображением надводной части. Опять нужен коэффициент, даже, в данном случае не коэффициент, а признак (флаг), определяющий, какое изображение выбрать. Такой флаг сформируем в альфа канале изображения надводной части – просто записываем в альфу единицу (см. шейдеры LandUp.vsh и LandUp.psh).

Теперь подробнее рассмотрим то, что отложили.

4. Отражения

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

Рендеринг воды - отражения

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

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

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

Рендеринг воды - отражения

Для борьбы с этим разработано «ноу хау» (надеюсь, это не очередной велосипед и действительно раньше не применялось :). Обратите внимание – паразитное отражение идет от верхней части ландшафта, которая видна с позиции камеры, но не видна с той позиции, где находится виртуальная камера отражений. Так отсечём эту часть! Как это сделать? Самый простой признак – нормали. Передаём в вертексный шейдер позицию камеры отражений (помните, мы не перемещаем саму камеру), вычитаем из этой позиции координаты вертекса – получаем вектор, направленный из вертекса на камеру отражений. Dot Product такого вектора с нормалью вертекса будет отрицательным для вертексов, нормаль которых направлена от камеры.

Направим результат в свободный слот текстурный координат:

  // c7 - Camera Pos (y = -y)
  sub r0, c7, v0
  dp3 oT1, r0, v1

А в пиксельном шейдере отсечём рендер этих частей:

  texkill t1

Смотрите полный код в LandReflUp.vs и LandReflUp.ps, сравните его с кодом LandUp.vs и LandUp.ps, которые выполняют обычный рендер надводной части ландшафта.

5. Вода

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

Сгенерируем конус – расположим вертекс номер 0 прямо под камерой (в центре) и три круга вертексов с радиусами 3, 10 и 90 соответственно, вокруг центра. Центральный вертекс и первый круг лежат в области усечения (конуса), то есть координата Y = -0.3, второй круг – это внешняя граница области усечения, то есть тоже Y = -0.3, а третий круг – это граница основания конуса. Расположим его не на Y = 0, а чуть выше, чтобы устранить шов между водой и небом. В альфу цвета нижнего вертекса запишем &h10 (шестнадцатеричное 10, то есть 16 или 16/255 в шейдерном представлении). Это почти полная прозрачность. В первый круг запишем &hC0, а во второй &hFF – полная непрозрачность. В третий круг, по идее, тоже нужно записать &hFF, но мы несколько снизим эту величину. На таком расстоянии мы уже не видим подводную часть ландшафта, а только цвет воды (вспомните, при рисовании подводной части мы очищали буфер этим цветом).

Код генерации цилиндра:

vBuffer(0).Pos = Vec3(0, -0.3, 0)
vBuffer(0).Color = &H10FFFFA0
For n = 0 To SectCount - 1
  vBuffer(n + 1).Pos = Vec3(3 * Sin(n * tmp), -0.3, -3 * Cos(n * tmp))
  vBuffer(n + 1).Color = &HC0AFFF80
  vBuffer(n + 1 + SectCount).Pos = Vec3(10 * Sin(n * tmp), -0.3, -10 * Cos(n * tmp))
  vBuffer(n + 1 + SectCount).Color = &HFF00FF40
  vBuffer(n + 1 + SectCount * 2).Pos = Vec3(90 * Sin(n * tmp), 0.3, -90 * Cos(n * tmp))
  vBuffer(n + 1 + SectCount * 2).Color = &H40000000

  iBuffer(n * 5 * 3 + 0) = 0
  iBuffer(n * 5 * 3 + 1) = n + 1
  iBuffer(n * 5 * 3 + 2) = ((n + 1) Mod SectCount) + 1

  iBuffer(n * 5 * 3 + 3) = n + 1
  iBuffer(n * 5 * 3 + 4) = n + SectCount + 1
  iBuffer(n * 5 * 3 + 5) = ((n + 1) Mod SectCount) + SectCount + 1

  iBuffer(n * 5 * 3 + 6) = n + 1
  iBuffer(n * 5 * 3 + 7) = ((n + 1) Mod SectCount) + SectCount + 1
  iBuffer(n * 5 * 3 + 8) = ((n + 1) Mod SectCount) + 1

  iBuffer(n * 5 * 3 + 9) = n + SectCount + 1
  iBuffer(n * 5 * 3 + 10) = n + SectCount * 2 + 1
  iBuffer(n * 5 * 3 + 11) = ((n + 1) Mod SectCount) + SectCount * 2 + 1

  iBuffer(n * 5 * 3 + 12) = n + SectCount + 1
  iBuffer(n * 5 * 3 + 13) = ((n + 1) Mod SectCount) + SectCount * 2 + 1
  iBuffer(n * 5 * 3 + 14) = ((n + 1) Mod SectCount) + SectCount + 1
Next n

Обратите внимание – заполнена не только альфа цвета, но и «R», «G», «B» каналы, какова функция этих величин?

Сначала рассмотрим способ формирования анимированной текстуры нормалей. У нас есть 64 файла PNG с кадрами анимации, размером 64*64. Создаём объёмную (Volume) текстуру с размером 64*64*64 и загружаем туда эти изображения послойно. Теперь, плавно изменяя текстурную координату «Z», мы можем «прокручивать» эту анимацию, анимация при этом получается более плавной, чем при простой смене кадров, за счёт линейной фильтрации текстуры – попадая между кадрами, мы получаем их интерполяцию.

Но 64*64 – это небольшой размер, при таком размере будет отчётливо заметна периодичность текстуры, либо придётся сильно увеличить масштаб текстуры, тем самым снижая детализацию. Можно применить так называемую Detail текстуру, причём в качестве таковой можно использовать ту же самую основную текстуру. Можно просто смешать две текстурные выборки с разным масштабом (простейший вариант – полусумма), но лучше учесть, что в дали лучше видно более крупные волны, а в близи наоборот, мелкие. Коэффициент интерполяции запишем в канал «B» цвета вертекса.

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

  v4.x = QTime * 0.1
  v4.x = v4.x - Int(v4.x)
  v4.y = cPos.y * 1.5 ’Масштаб бамп текстуры
  v4.z = 0
  v4.w = 1
  Dev.SetVertexShaderConstantF 4, VarPtr(v4), 1
  v4.x = cPos.x * 1.5 * 0.3
  v4.y = cPos.z * 1.5 * 0.3
  v4.z = 0
  v4.w = 0
  Dev.SetVertexShaderConstantF 5, VarPtr(v4), 1

В первой строке в «X» первой константы записывается текущее время, умноженное на 0.1, что определяет скорость анимации текстуры по оси «Z», в следующей строке вычисляется дробная часть этой величины. В «Y» записываем «Y» координату камеры, умноженную на масштаб текстуры. Через следующую константу передаём так же «X» и «Z»камеры, умноженные на тот же масштаб и на величину 0.3 – вспомните, это высота камеры над плоскостью усечения конуса.

Теперь сам вертексный шейдер:

  vs_2_0

  // c0..c3 - матрица WorldVievProj
  // c4 - x = время, y=CamPos.y*масштаб текстуры, z=0, w=1
  // c5 - x=CamPos.x*масштаб текстуры, y=CamPos.z*масштаб текстуры, z=0, w=0

  dcl_position v0
  dcl_color v1

  def c6, 0.23, 0.23, 1, 1

  m4x4 r0, v0, c0
  mov oPos, r0

  mad r0, v0.xzyw, c4.yyzw, c4.zzxz
  add r0, r0, c5
  mov oT0, r0
  mul oT1, r0, c6

  mov oD0, v1

Рассмотрим эту строку:

  mad r0, v0.xzyw, c4.yyzw, c4.zzxz

Покомпонентно:

  r0.x = v0.x*c4.y+c4.z

То есть мы умножили «X» камеры на масштаб (c4.y) и прибавили 0 (c4.z). Таким образом получена текстурная координата «X», «Y» получается аналогичным способом, но не из «Y», а из «Z» координаты камеры (не нужно объяснять почему?). Текстурная координата «Z» получена так:

  r0.z = v0.y*c4.z+c4.x

То есть в неё фактически записывается c4.x, то есть время (c4.z = 0). Так одной инструкцией мы рассчитали все три текстурные координаты с учётом высоты камеры и анимации по времени. В следующих строках добавлена поправка на горизонтальное смещение камеры, полученный результат записан в oT0, результат умножен на коэффициент для более крупной текстуры (выбран коэффициент 0.23) и записан в следующий слот для текстурных координат – oT1.

Перейдём к пиксельному шейдеру, сначала мы считываем текстуру дважды с Detail и обычных текстурных координат и интерполируем полученные семплы:

  texld r0, t0, s0
  texld r1, t1, s0

  lrp r0.rg, v0.b, r0, r1

Так мы получили в r0.rg необходимые значения для бампа, можно было бы на этом закончить и отправить в oC0 эти величины, но мы осуществим ещё один эффект. На реальной воде от угла зрения зависит не только коэффициент отражений-преломлений, но так же меняется величина отклонения по вертикали и по горизонтали, это выражается в том, что контрастные точечные объекты при приближении к горизонту дают не круглые, а вытянутые по вертикали отражения.

Рендеринг воды - отражение солнца

Необходимые коэффициенты запишем в пока свободные каналы «R» и «G» цвета вертекса воды, а в пиксельном шейдере их применим:

  sub r0.rg, r0, c0.r
  mul r0.rg, r0, v0
  mad r0.a, r0.g, c0.a, v0.a
  add r0.rg, r0, c0.r

Тут мы отняли от значений «R» и «G» 0.5 (привели к диапазону -0.5..0.5), умножили на наши коэффициенты и привели обратно к диапазону 0..1. И особого внимания заслуживает третья строка:

  mad r0.a, r0.g, c0.a, v0.a

Мы не просто записали в альфа-канал значение v0.a (вспомните, это коэффициент преломлений, он нам понадобится в шейдере World.psh). Мы сделали небольшую поправку на наклон волны, ведь угол наклона нормали поверхности к камере складывается из угла наклона луча зрения к плоскости воды и локального угла наклона волны к вертикали. А последний угол у нас записан в «G» канале бамп текстуры (в «R» канале отклонение по горизонтали).

Рендеринг воды - отражения

6. Подводная часть ландшафта

Осталось подробнее рассмотреть подводную часть. Наша задача – отрисовать её так же, как верхнюю часть, добавив текстуру каустиков и туман цвета воды, зависящий от глубины. Так мы имитируем неполную прозрачность воды. В альфа-канале полученного изображения необходимо сформировать карту зависимости величины преломлений от глубины. Мировую матрицу можно умножить на MatrixScaling (1, 0.8, 1), тем самым имитируя кажущееся сплющивание предметов под водой.

Передаём в вертексный шейдер четыре константы:

  v4 = Vec4(0.707, 0.707, 0, QTime * 0.1) ’ Вектор света
  v4.w = v4.w - Int(v4.w)
  Dev.SetVertexShaderConstantF 4, VarPtr(v4), 1
  v4 = Vec4(1, 1, 1, 0)             ’ Diffuse
  Dev.SetVertexShaderConstantF 5, VarPtr(v4), 1
  v4 = Vec4(0.3, 0.34, 0.35, -0.1)  ’ Ambient, a - коэф. преломления
  Dev.SetVertexShaderConstantF 6, VarPtr(v4), 1
  v4 = Vec4(0.1, 0.15, 0.2, -1 / 4) ’ Цвет воды, a - прозрачность
  Dev.SetVertexShaderConstantF 7, VarPtr(v4), 1

Это вектор света, Diffuse, Ambient и цвет воды. Через «A» (или «W») каналы этих величин так же передаём время, коэффициент преломления и коэффициент прозрачности воды. Не буду останавливаться на расчёте освещения, оно стандартно. После расчёта света в «A» канал записываем коэффициент преломлений, скорректированный по глубине:

  mul oD0.a, c6.a, v0.y

А через oD1 (спекуляр) передаём в пиксельный шейдер цвет воды с коэффициентом прозрачности, так же скорректированным по глубине:

  mov oD1, c7
  mul oD1.a, c7.a, v0.y

Для анимации каустиков мы используем тот же приём, что и для анимации текстуры воды. В альфа-канале Volume текстуры, которую мы использовали для воды, у нас записана текстура каустиков (зачем создавать отдельную текстуру?)

В пиксельном шейдере после семплирования текстуры камня и умножения на Diffuse (всё стандартно), полученный результат умножаем на каустики:

  mul r0, r0, r1.a

r1.a лежит в пределах 0.5..1, то есть, умножая на эту величину, мы получаем небольшое затемнение. Его не нужно корректировать – под водой всегда немного темнее, даже если она абсолютно прозрачна, ведь часть света отражается от поверхности и не попадает под воду.

Дальше интерполируем результат с цветом воды:

  lrp r1, v1.a, v1, r0

Записываем в «A» канал коэффициент преломлений:

  mov r1.a, v0.a

И отправляем на выход шейдера:

  mov oC0, r1

7. Заключение

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

Более подробно про формулу Френеля можно почитать тут:
http://physics.nad.ru/Physics/Cyrillic/rays_txt.htm

В этой статье рассказывается про создание реалистичной поверхности воды с использованием GLSL:
http://www.gamedev.ru/code/articles/?id=4361

8. Демо-программа

Скачать демонстрационную программу вместе с исходным кодом можно здесь:
Water_Rendering.zip (1.3 Мб)

Рендеринг воды - отражения

 


 

Статья участвовала в конкурсе статей по программированию #4 (2009).