Создание океана с помощью XNA

Опубликовано 27 января 2010 г. 11:00 | Coding4Fun

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

Автор: Льюис Ингентрон (Louis Ingenthron)
Исходный код: https://xnaocean.codeplex.com/
Сложность: средняя
Необходимое время: 1–4 часа
Затраты: бесплатно
ПО: Visual C# Express, .NET Framework 3.5, XNA Game Studio 3.1
Оборудование: ПК под управлением Windows

Введение

В любой игре нужны границы, например окно, отделяющее игрока от космического пространства, или кирпичная стена. Когда я впервые играл в Splinter Cell: Double Agent на уровне Cozumel Cruise, я потратил добрых 5 минут, просто глазея на океан, который служил для игрока своего рода границей. Красивый закат, волны бегущие по океану… это было здорово.

Когда мне поставили задачу создать океан для игры про карибских пиратов, я уже понимал, что он должен выглядеть величественно — как в Splinter Cell. Слишком во многих играх, особенно некоммерческих (indie games), просто помещают одну или две дешевых текстуры на синий квадрат. Но по моему мнению, создание великолепно выглядящей границы вроде океана может на деле связывать воедино элементы вашей окружающей среды и придавать ей некий дополнительный блеск.

А теперь уточню: мы здесь не будем делать океан, как в Crysis. Нам нужно нечто хорошо выглядящее и кажущееся бескрайним, поэтому большая часть нашей работы будет выполняться шейдерами DirectX 9. C# и XNA просто помогут создать подходящий проект. (XNA — фантастически удобная вещь для быстрой разработки и подготовки технологических демонстраций; кроме того, он включает полную систему Indie Games Distribution.)

Приступаем

Рассмотрим базовый проект. Вы можете скомпилировать его и тут же запустить. После запуска вы получите возможность поворачивать камеру мышью и двигаться с помощью стандартных клавиш WASD (или использовать первый геймпад со стандартным управлением для шутеров от первого лица, если предпочтете Xbox 360), но все, что вы увидите, — небесный куб (skybox) на бесконечной дистанции. В этой статье я не стану вдаваться в детали рендеринга небесных кубов, потому что об этом уже написано много статей. Но вы должны быть знакомы с концепцией кубических карт (Cube Maps), которые фактически являются шестью квадратными текстурами, представляющими шесть сторон куба.

clip_image001

Итак, переходим непосредственно к коду. Начнем с большой синей плоскости. Откроем Ocean.cs. Вы заметите, что там уже есть кое-какой код. В функции Load() настроены и объявлены некоторые вершины. (И вновь в этой статье я исхожу из того, что у вас есть базовое понимание рендеринга трехмерных сцен.) Эти вершины образуют два треугольника, которые формируют очень большую плоскость XZ с Y=0. Так как наш океан будет обрабатываться только пиксельным шейдером, плоскость должна быть намного больше, чем ваша плоскость дальнего отсечения (far-clip plane), настроенная в проекционной матрице. Так что рисуем плоскость. В функцию Draw() объекта Ocean добавьте такие строки:

C#

 Global.Graphics.VertexDeclaration = OceanVD;

Global.Graphics.DrawUserPrimitives<VertexPositionNormalTexture>

(PrimitiveType.TriangleList, OceanVerts, 0, 2);

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

Перейдем в Solution Explorer. Щелкните правой кнопкой мыши проект Content в проекте игры, а затем выберите Add | New Item. Укажите шаблон «Effect File» и присвойте ему имя OceanShader.fx. Сейчас мы просто тестируем формирование квадратов, поэтому ничего особенного в этом файле эффектов пока делать не будем. Я удалил заранее сгенерированные комментарии и изменил возвращаемое значение пиксельного шейдера на float4(0,0,1,1), чтобы получить синий цвет вместо красного.

А теперь вернемся к Ocean.cs. Нам нужно добавить для шейдера новую переменную класса. Это осуществляется с помощью класса Microsoft.Xna.Framework.Graphics.Effect. Мы добавляем его в начало своего класса:

C#

 // Необходимый контент для океана

private Effect oceanEffect; 

И связываем его с нашим FX-файлом в функции загрузки такой строкой:

C#

 // Загрузка шейдера

oceanEffect = Content.Load<Effect>("OceanShader"); 

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

C#

 public void Draw(GameTime gameTime, Camera cam, 

    TextureCube skyTexture, Matrix proj)

{

// Запускаем шейдер

oceanEffect.Begin();

oceanEffect.CurrentTechnique.Passes[0].Begin();

// Задаем преобразования

oceanEffect.Parameters["World"].SetValue(Matrix.Identity);

oceanEffect.Parameters["View"].SetValue(cam.GetViewMatrix());

oceanEffect.Parameters["Projection"].SetValue(proj);

oceanEffect.CommitChanges();

// Рисуем геометрические элементы

Global.Graphics.VertexDeclaration = OceanVD;

Global.Graphics.DrawUserPrimitives<VertexPositionNormalTexture>

(PrimitiveType.TriangleList, OceanVerts, 0, 2);

// И на этом все!

oceanEffect.CurrentTechnique.Passes[0].End();

oceanEffect.End();

}

Прежде чем рисовать, нужно начать Effect и EffectPass. Поскольку у нас всего один проход (pass) в нашем шейдере, мы просто начинаем первый. Далее мы задаем матрицы преобразования, определенные в шейдере. (Подробнее о матрицах преобразований читайте здесь.) Потом сообщаем Effect выполнить CommitChanges(), который, по сути, сбрасывает изменения параметров графической карте, что и позволяет нам рисовать. Здесь вступает в игру наш старый код для рисования плоскости. И, наконец, мы заканчиваем EffectPass и Effect. В итоге программа должна нарисовать большую синюю плоскость, где будет находиться наш океан.

clip_image002

Отражения карты неба

Один из лучших способов придать воде настоящий вид — добавить отражение. Видели когда-нибудь, как покрытые снегом вершины гор отражаются в озере? Очень красочное зрелище. Сделаем так, чтобы в нашем океане отражалось небо. Для этого вернемся к файлу OceanShader.fx. Именно в нем предстоит проделать большую часть работы. От нас требуется передать шейдеру кубическую карту неба. Добавьте этот параметр в начало FX-файла:

HLSL

textureCUBE cubeTex;

samplerCUBE CubeTextureSampler = sampler_state

{

Texture = <cubeTex>;

MinFilter = anisotropic;

MagFilter = anisotropic;

MipFilter = anisotropic;

AddressU = wrap;

AddressV = wrap;

};

Затем нужно заменить структуры Vertex теми, которые реально отражают настоящие вершины. Они должны выглядеть примерно так:

HLSL

struct VertexShaderInput

{

float3 Position : POSITION0;

float3 normal : NORMAL0;

float2 texCoord : TEXCOORD0;

};

struct VertexShaderOutput

{

float4 Position : POSITION0;

float2 texCoord : TEXCOORD0;

float3 worldPos : TEXCOORD1;

};

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

HLSL

VertexShaderOutput output;

float4 worldPosition = mul(float4(input.Position,1), World);

float4 viewPosition = mul(worldPosition, View);

output.Position = mul(viewPosition, Projection);

output.texCoord = input.texCoord*100;

output.worldPos = worldPosition.xyz;

return output;

Для отражений необходима еще одна переменная. Отражение на поверхности требует наличия исходного вектора и вектора нормалей поверхности. Исходный вектор можно создать вычитанием позиции камеры из мировой позиции ввода. Чтобы получить позицию камеры, мы могли бы извлечь ее из View Matrix, но это весьма проблемный путь. Вместо этого мы просто добавляем переменную float3 в начало нашего FX-файла:

HLSL

float3 EyePos;

Теперь обновляем функцию пиксельного шейдера:

HLSL

float3 diffuseColor = float4(0,0,1,1);

float3 normal = float3(0,1,0);

float3 cubeTexCoords = reflect(input.worldPos - EyePos,normal);

float3 cubeTex = texCUBE(CubeTextureSampler,cubeTexCoords).rgb;

return float4((cubeTex*0.8)+(diffuseColor*0.2),1);

Что же она делает? Во-первых, устанавливает diffuseColor, который является цветом самого океана, но не дает ему выглядеть большим зеркалом. Затем мы предполагаем, что нормаль направлена строго вверх (это мы еще изменим). Далее нам нужно получить координаты текстуры для карты неба. Мы будем отражать вектор со стороны глаз игрока от нормали поверхности. Кубические карты принимают трехкомпонентные векторы, и их даже не требуется нормализовать, так что у нас есть все, что нужно. Мы используем эти координаты и на деле сохраняем RGB-значения текстуры (зачем небу нужен альфа-канал?). Наконец, мы комбинируем 80% цвета отраженного неба с 20% диффузного цвета. Все ли готово? Нет. Мы определили переменные, но так и не присвоили им значения. Вернемся к Ocean.cs и добавим строки:

C#

 oceanEffect.Parameters["EyePos"].SetValue(cam.Position);

// Задаем текстуру неба

oceanEffect.Parameters["cubeTex"].SetValue(skyTexture); 

Вот теперь все готово! Вы должны увидеть большую поверхность синей воды, в которой отражается небо. Круто, да? Но вода пока еще не очень похожа на воду?

clip_image003

Картынормалей

Добавим волнение на поверхность. Четыре карты нормалей (normal maps) уже включены в проект Content, поэтому нам остается лишь добавить массив Texture2D в наш класс Ocean и загрузить эти текстуры. Массив текстур мы назвали OceanNormalMaps:

C#

 private Texture2D[] OceanNormalMaps; 

И загружаем его в функции Load() следующим образом:

C#

 // Загрузка карт нормалей

OceanNormalMaps = new Texture2D[4];

for (int i = 0; i < 4; i++)

OceanNormalMaps[i] = Content.Load<Texture2D>("Ocean" + (i + 1) + "_N");

Что мы собираемся делать с этими текстурами? На самом деле все очень просто: мы будем интерполировать (lerp) между ними. Вернитесь к FX-файлу и добавьте два новых регистра текстур (интерполировать будем только между двумя текстурами в промежуточный момент времени) и переменную типа float для задания реального значения момента времени:

HLSL

 float textureLerp;

texture2D normalTex;

sampler2D NormalTextureSampler = sampler_state

{

Texture = <normalTex>;

MinFilter = anisotropic;

MagFilter = anisotropic;

MipFilter = anisotropic;

AddressU = wrap;

AddressV = wrap;

};

texture2D normalTex2;

sampler2D NormalTextureSampler2 = sampler_state

{

Texture = <normalTex2>;

MinFilter = anisotropic;

MagFilter = anisotropic;

MipFilter = anisotropic;

AddressU = wrap;

AddressV = wrap;

};

Далее изменяем код нашего пиксельного шейдера:

HLSL

float3 diffuseColor = float4(0,0,1,1);

float4 normalTexture1 = tex2D(NormalTextureSampler, input.texCoord);

float4 normalTexture2 = tex2D(NormalTextureSampler2, input.texCoord);

float4 normalTexture = (textureLerp*normalTexture1)+

((1-textureLerp)*normalTexture2);

float3 normal = ((normalTexture)*2)-1;

normal.xyz = normal.xzy;

normal = normalize(normal);

float3 cubeTexCoords = reflect(input.worldPos-EyePos,normal);

float3 cubeTex = texCUBE(CubeTextureSampler,cubeTexCoords).rgb;

return float4((cubeTex*0.8)+(diffuseColor*0.2),1);

Теперь обсудим, что у нас получилось. Первым делом мы осуществляем выборку новых карт нормалей и переключаемся между ними через интервал, задаваемый значением ранее объявленной переменной. Тут нет ничего слишком сложного. Но потом нам нужно преобразовать RGB-значение в нормаль. В коде видно, что принимается RGB-значение, умножается на 2, и из результата вычитается единица. То есть принимается сжатое RGB-значение [0:1] и преобразуется в [-1:1] для полного диапазона нормалей. В следующей строке компоненты нормали переупорядочиваются. Дело в том, что по сложившейся традиции карты нормалей размещаются на плоскости XY, но наш океан находится на плоскости XZ — вот почему мы меняем местами компоненты Y и Z. И вот нормаль нормализована. Всегда нормализуйте свои нормали! Остальное идентично тому, что было раньше. Давайте присвоим значения переменным, которые мы определили, и посмотрим, как работает вся эта штуковина. Вернитесь к Ocean.cs и добавьте в него такой код для рисования:

C#

 // Выбираем и задаем текстуры океана

int oceanTexIndex = ((int)(gameTime.TotalGameTime.TotalSeconds) % 4);

oceanEffect.Parameters["normalTex"].SetValue(

OceanNormalMaps[(oceanTexIndex + 1) % 4]);

oceanEffect.Parameters["normalTex2"].SetValue(

OceanNormalMaps[(oceanTexIndex) % 4]);

oceanEffect.Parameters["textureLerp"].SetValue(

(((((float)gameTime.TotalGameTime.TotalSeconds) - 

(int)(gameTime.TotalGameTime.TotalSeconds)) * 2 - 1) * 0.5f) 

+ 0.5f); 

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

clip_image004

Анимация и смешивание

Ну вот, уже что-то напоминающее океан. Но где это видано, чтобы вода двигалась только вверх и вниз — особенно в океане? Давайте добавим немного анимации! Первым делом реализуем прокрутку координат текстур в зависимости от времени. Добавим переменную time типа float в начало FX-файла:

HLSL

 float time = 0;

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

HLSL

 float4 normalTexture1 = tex2D(NormalTextureSampler, 

input.texCoord+float2(time,time));

float4 normalTexture2 = tex2D(NormalTextureSampler2, 

input.texCoord+float2(time,time));

Затем просто задайте параметр time в методе Draw() из Ocean.cs:

C#

 // Задаем время для перемещения волн

oceanEffect.Parameters["time"].SetValue(

(float)gameTime.TotalGameTime.TotalSeconds * 0.02f);

Теперь по воде побегут волны, если вы запустите программу.

Можно внести еще одно улучшение. Что лучше одной перемещаемой поверхности? Как насчет двух? Это, по сути, задача аппроксимации эффекта смещения нормалей поверхности в зависимости от изменения точки зрения (parallax normal mapping). Изменим наш код пиксельного шейдера:

HLSL

 float3 diffuseColor = float4(0,0,1,1);

float4 normalTexture1 = tex2D(NormalTextureSampler, 

input.texCoord*0.1+float2(time,time));

float4 normalTexture2 = tex2D(NormalTextureSampler2, 

input.texCoord*0.1+float2(time,time));

float4 normalTexture = (textureLerp*normalTexture1) +

((1-textureLerp)*normalTexture2);

float4 normalTexture3 = tex2D(NormalTextureSampler, 

input.texCoord*2+float2(-time,-time*2));

float4 normalTexture4 = tex2D(NormalTextureSampler2, 

input.texCoord*2+float2(-time,-time*2));

float4 normalTextureDetail = (textureLerp*normalTexture3) +

((1-textureLerp)*normalTexture4);

float3 normal = (((0.5*normalTexture) + 

(0.5*normalTextureDetail))*2) - 1;

normal.xyz = normal.xzy;

normal = normalize(normal);

float3 cubeTexCoords = reflect(input.worldPos-EyePos,normal);

float3 cubeTex = texCUBE(CubeTextureSampler,cubeTexCoords).rgb;

return float4((cubeTex*0.8)+(diffuseColor*0.2),1); 

Здесь мы добавили две раздельные группы координат переключаемых текстур. Первая и вторая умножаются на 0.1, что сильно увеличивает высоту волн. Третья и четвертая умножаются на 2, поэтому на них высота волн намного меньше, и они гораздо быстрее движутся в противоположном направлении. Благодаря этому удается имитировать не только волнение океана, но и его реакцию на порывы ветра. Пиксели комбинируются перед преобразованием в нормали. Запускайте программу — теперь вы должны увидеть новый и весьма красивый океан!

clip_image005

Заключение

Вот, получите: красивый безбрежный океан! Так как основная часть работы выполняется в пиксельном шейдере, он будет поставлен в самый конец буфера кадра (depth buffer) и будет визуализироваться бесконечно. Кроме того, наш океан лучше выглядит, когда вы смотрите на него лишь немного сверху. Если смотреть на уровне океана, станет заметно, что отражается только небо. Вероятно, вы также обратите внимание на то, что океан выглядит несколько мультяшным в нашем примере, но это вызвано использованием небесного куба, выполненного в мягких пастельных тонах. Более реалистичный небесный куб позволит добиться и более реалистичного вида океана.

С нетерпением жду отзывов о том, кто и как использует этот эффект! Если вы пользуетесь им, пожалуйста, пришлите мне сообщение по электронной почте с экранным снимком.

Обавторе

Льюис Ингентрон (Louis Ingenthron) — разработчик игр, живет в Орландо, штат Флорида. Работает над коммерческими играми для консольных приставок и ПК, но вдобавок является владельцем собственной компании FV Productions, выпускающей некоммерческие игры. Наибольшую известность получила его игра Unsigned с открытым исходным кодом, написанная с применением XNA. Кроме того, он специализируется на программировании графики в реальном режиме времени. Работает с XNA с момента появления версии 2.0 Beta и примерно столько же использует C#. Знает еще несколько языков программирования, в том числе C, C++ и Java. Кстати, иногда он занимается даже веб-разработкой и созданием программ с использованием Flash. Льюис также пишет на веб-сайте MSDN Coding4Fun, ежемесячно выкладывая новые статьи.