Entries:
Comments:
Posts:

Loading User Information from Channel 9

Something went wrong getting user information from Channel 9

Latest Achievement:

Loading User Information from MSDN

Something went wrong getting user information from MSDN

Visual Studio Achievements

Latest Achievement:

Loading Visual Studio Achievements

Something went wrong getting the Visual Studio Achievements

Making an Ocean with XNA

In this article, Louis Ingenthron will show you how to create a simple endless ocean for an XNA C# game.

Introduction

Every game needs boundaries, such as a window separating the player from space or a brick wall. When I first played Splinter Cell: Double Agent's Cozumel Cruise level, I spent a good 5 minutes just looking at the ocean that kept the player in bounds. It was a beautiful sunset with waves moving across the ocean endlessly outward... and it was neat.

When I was tasked with creating a great ocean for a Caribbean pirate game, I knew it had to look great, like Splinter Cell's. Too many games, especially indie games, just put a cheap texture or two on a blue quad. But in my opinion, having a great looking boundary like an ocean can really tie your environment together and add a layer of polish.

Now, just to clarify: we aren't going for the ocean in Crysis here. We want something that looks good and seems to be endless, so most of our work will be in the DirectX 9 Shaders. We will be using C# and XNA to build a project around it (XNA is fantastic for rapid development and tech demos, as well as its full Indie Games Distribution system).

Getting Started

Let's take a look at the base project. You should be able to compile and run it on the spot. When you get in, you will be able to turn the camera with the mouse and move with WASD (or use the first gamepad with standard FPS controls if you converted to run on Xbox360), but all that's there is the skybox at an infinite distance away. I won't go into rendering skyboxes in this article because there are many articles about it already. But you should be familiar with the concept of Cube Maps, which are basically six square textures representing the six faces of a cube.

clip_image002

So, let's get right into the code. We'll start with a big blue plane. Open up Ocean.cs. You will notice that we already have some code in here. We have some vertices set up in the Load() function and a vertex declaration created (Again, in this article I assume you have a basic understanding of 3D rendering). These vertices will create two triangles that make a very large XZ plane at Y=0. Since our ocean will be processed only in the pixel shader, the plane should be much larger than your far-clip plane set up in your projection matrix. So let's draw the plane. In the Ocean's Draw() function, add these lines:

C#

Global.Graphics.VertexDeclaration = OceanVD;
Global.Graphics.DrawUserPrimitives<VertexPositionNormalTexture>
    (PrimitiveType.TriangleList, OceanVerts, 0, 2);

This code tells the graphics device what kind of vertex we are using, and tells it to draw two consecutive triangles. But XNA uses only Shaders; it doesn't support fixed function.

Let's go over to our Solution Explorer. Right click on the Content project in our game project, and click Add -> New Item. Choose the "Effect File" template and name it OceanShader.fx. We are just testing to get the quads on there now, so we won't do much in this effect file yet. I cleaned up the pre-generated comments and changed the pixel shader return value to float4(0,0,1,1) to make it blue instead of red.

Now, let's go back to Ocean.cs. We need to add a new class variable for the shader. This is encompassed by the Microsoft.Xna.Framework.Graphics.Effect class. We add this to the top of the class:

C#

// the ocean's required content
private Effect oceanEffect; 

And we actually link that to our FX file in the load function with this:

C#

// load the shader
oceanEffect = Content.Load<Effect>("OceanShader"); 

Now we just need to set the shader variables in the draw function and tell the shader to begin and end. Our draw function now looks like this:

C#

public void Draw(GameTime gameTime, Camera cam, 
        TextureCube skyTexture, Matrix proj)
{
    // start the shader
    oceanEffect.Begin();
    oceanEffect.CurrentTechnique.Passes[0].Begin();

    // set the transforms
    oceanEffect.Parameters["World"].SetValue(Matrix.Identity);
    oceanEffect.Parameters["View"].SetValue(cam.GetViewMatrix());
    oceanEffect.Parameters["Projection"].SetValue(proj);

    oceanEffect.CommitChanges();

    // draw our geometry
    Global.Graphics.VertexDeclaration = OceanVD;
    Global.Graphics.DrawUserPrimitives<VertexPositionNormalTexture>
        (PrimitiveType.TriangleList, OceanVerts, 0, 2);

    // and we're done!
    oceanEffect.CurrentTechnique.Passes[0].End();
    oceanEffect.End();
}

Before we draw, we must first begin an Effect and an EffectPass. Since we only have one pass in our shader, we can just begin the first one. Next, we set the Transform matrices defined in the shader (Read here for more information on the transform matrices). Then we tell the Effect to CommitChanges(), which basically flushes the parameter changes down the tube to the graphics card so we can draw. Now we see our old code to draw the plane. Finally, we end the EffectPass and the Effect. The program should run now, with a big blue plane where your ocean is.

clip_image004

Skymap Reflections

One of the best ways to make water look really neat is to add reflection. Ever seen a snow-covered mountain reflected on the surface of a lake? It's gorgeous. Let's make our ocean reflect the sky. Go back to the OceanShader.fx file. This is where we will do the majority of our work. We need to give the shader the sky cubemap. Add this parameter to the top of the FX file:

HLSL

textureCUBE cubeTex;
samplerCUBE CubeTextureSampler = sampler_state
{
    Texture = <cubeTex>;
    MinFilter = anisotropic;
    MagFilter = anisotropic;
    MipFilter = anisotropic;
    AddressU = wrap;
    AddressV = wrap;
};

Next, we need to replace the Vertex structures with ones that match the actual vertices. They should look something like this:

HLSL

struct VertexShaderInput
{
    float3 Position            : POSITION0;
    float3 normal              : NORMAL0;
    float2 texCoord            : TEXCOORD0;
};

struct VertexShaderOutput
{
    float4 Position            : POSITION0;
    float2 texCoord            : TEXCOORD0;
    float3 worldPos         : TEXCOORD1;
};

We've now added two variables. The texCoord is pretty simple. We just pass it along (and multiply it). The worldPos is already calculated for us, so we can just assign it. Since it's an ocean, we can just assume that the normal vector is straight up (why would we have a slanted ocean?). Our vertex shader should look like this:

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;

To do reflections, we need one more variable. A reflection off a surface requires a source vector and a surface normal vector. We can create our source vector by subtracting the camera position from the input's world position. To get the camera position, we could extract it from the View Matrix, but that's a pain. Instead, we'll just add a float3 variable to the top of our FX file like so:

HLSL

float3 EyePos; 

Now we update our pixel shader function:

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); 

So what is this actually doing? First we set up the diffuseColor, which is the color of the ocean itself, blue, which keeps it from looking like a big mirror. Next we assume the normal is straight up (We'll change this later). Then we need to get the texture coordinates for the skymap. We will reflect the vector from the eye off of the surface normal. Cubemaps take 3-component vectors, and don't even need them to be normalized, so that's all we need there. Then we use those coordinates and actually store the texture lookup's RGB values (what sky has an alpha?). Finally, we combine 80% of the reflected sky color with 20% of the diffuse color. Can we run it now? Well, no. We defined those variables up top, but never set them. Let's go back to the Ocean.cs and add in some more parameter mutators:

C#

oceanEffect.Parameters["EyePos"].SetValue(cam.Position);
// set the sky texture
oceanEffect.Parameters["cubeTex"].SetValue(skyTexture); 

Now we can run it! You should see a big flat body of blue water that reflects the sky. Cool, huh? But it's not very watery yet, is it?

clip_image006

Normal Mapping

Let's add some chop to the surface. Four normal maps are already be included in the Content project, so we just need to add a Texture2D array to our Ocean class and load in these textures. We'll call the texture array OceanNormalMaps:

C#

private Texture2D[] OceanNormalMaps; 

And load them in the Load() function like this:

C#

// load the normal maps
OceanNormalMaps = new Texture2D[4];
for (int i = 0; i < 4; i++)
    OceanNormalMaps[i] = Content.Load<Texture2D>("Ocean" + (i + 1) + "_N");

What are we going to do with these textures? It's simple really: we'll lerp between them. Switch to the FX file and add two new texture registers (we'll only be lerping between two at any given time) and a float for the actual lerping value:

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;
};

Next, we change our pixel shader code:

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); 

Alright, let's take a look at this. The first new thing we do is sample the new normal maps and lerp between them with the value declared earlier. Nothing too complex there. But then we need to convert the RGB value into a normal. You'll notice that we take the RGB, multiply by 2 and subtract by one. This simply takes the compressed RGB [0:1] value and converts it to [-1:1] for a full range of normals. The next line re-orders the normal's components. This is because the normal map is traditionally on the XY plane, but our ocean is on the XZ plane—so we swap the Y and Z components. Finally, we normalize our normal. Always normalize your normals! Everything after that is the same as before. Let's go set these variables we defined and get this thing working. Go back to Ocean.cs and add this code to the draw:

C#

// choose and set the ocean textures
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); 

You can do the math on this if you want, but basically it just cycles through the four textures and sets the lerp value so it's a continuous shift. After all that, you should have an interesting image when you run the program!

clip_image008

Animate and Blend

Now we have something that resembles water. But when was the last time you saw water that just moved up and down—especially in the ocean? Let's animate this a bit! The first thing we'll do is make the texture coordinates scroll based on time. Let's add a time float to the top of the FX file:

HLSL

float time = 0;

Next, let's scroll the texture coordinates in the normal map lookup:

HLSL

float4 normalTexture1 = tex2D(NormalTextureSampler, 
    input.texCoord+float2(time,time));
float4 normalTexture2 = tex2D(NormalTextureSampler2, 
    input.texCoord+float2(time,time));

Now, simply set the time parameter in the Ocean.cs's Draw():

C#

// set the time used for moving waves
oceanEffect.Parameters["time"].SetValue(
    (float)gameTime.TotalGameTime.TotalSeconds * 0.02f);

Your water should be moving now if you run it.

There is one more enhancement we can do. What's better than having one moving surface? How about two? It's basically parallax normal mapping. We will change our pixel shader code to look like this:

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); 

What we did here is add two separate, lerping texture coordinates together. The 1 and 2 have been scaled by 0.1, making the waves much larger. The 3 and 4 lookups are scaled by 2 so they are much smaller, and move much faster in the opposite direction. This makes it look like the ocean has an overall current and some smaller waves simulating wind. The pixels are then combined before being converted to normals. Go ahead and run it. You should see your nice, new ocean!

clip_image010

Conclusion

And there you have it: a nice, infinite ocean! Since the majority of the work is done in the pixel shader, it will go on to the very end of the depth buffer and tile infinitely. Also, this is best when you are only looking out at it. It becomes somewhat apparent to the user if you are looking at the level on the ocean and only the sky, not the level, reflects properly. You may also notice that the ocean looks a bit cartoony in our example, but the good news is that this is merely an effect of the skybox being done in a soft pastel style. A more realistic skybox means a more realistic ocean.

I'm looking forward to seeing how people use this effect! If you use it, please send me an email with a screenshot.

About The Author

Louis Ingenthron is a Game Developer in Orlando, FL. He works on commercial Console and PC titles, but runs his own Indie Games company, FV Productions, on the side. He is best known for his open source XNA rhythm game Unsigned and specializes in real-time Graphics programming. He has been working with XNA since the 2.0 Beta and with .NET C# for just as long. He is also familiar with several other development languages, such as C, C++, and Java. Occasionally, he can even be found doing web development and Flash. Louis also writes for MSDN's Coding4Fun website, contributing articles on a monthly basis.

Tags:

Follow the Discussion

Remove this comment

Remove this thread

close

Comments Closed

Comments have been closed since this content was published more than 30 days ago, but if you'd like to continue the conversation, please create a new thread in our Forums,
or Contact Us and let us know.