Dustin Horne

Developing for fun...

XNA Terrain with LOD - Part 6: View Frustrum Culling

So far in the terrain series we've learned how to create a heightmap based terrain, leverage a quadtree to navigate our terrain data, and add progressive level of detail by using our quadtree to step away our active vertices as we move further from the camera.  Today we're going to concentrate on view frustrum culling.  We're going to avoid drawing any geometry that is not in view of the camera.  If you haven't read through the first 5 parts of this tutorial you can find them on the Terrain Table of Contents page.

Why Use Culling?

The first thing we should address is why culling is important.  Previously we've talked about performance from a processing standpoint.  That is, we implemented the quad tree to reduce the number of vertices we had to check and add to our buffers.  Since our terrain is being generated on the CPU this is important, but equally as important is reducing the workload of the GPU.  Since there's no sense in rendering geometry that can't be seen by the player, we can reduce the amount of work the graphics card has to perform and improve performance.

I've mentioned it before, but I wanted to reiterate that the method I'm using for culling in this series is not the most performant method.  In fact, we're going to be performing quite a few checks against our view frustrum.  We're going to be checking for intersections with our bounding boxes so we can determine which geometry to draw.  These are fairly expensive tests and there are better ways to perform them, however my goal is to show you how to implement culling quickly and without a lot of extra complexity.  With a solid understanding of how it works, you could modify your code to perform different checks.  We may even investigate some additional possibilities at the end of the series.

Setting Up Culling

The first thing we need to do is prepare our tree for culling.  We want to be able to turn culling on and off so we'll be able to see our results.  Go ahead and open your QuadTree.cs file and let's add a property to our QuadTree class that allows us to enable and disable culling.

public bool Cull { get; set; }

Next we need to be able to tell if the bounding box for our quad node intersects with the view frustrum.  To accomplish this, we're going to overload our Contains method.  Remember previously in the tutorial we created a Contains method that accepts a point and determines whether that point is within the bounding box for the node.  This time we're going to create a new Contains method just below it that accepts a bounding frustrum which we'll be constructing soon.  For now, go ahead and open your QuadNode.cs file and add the following Contains method just below or above the one we created earlier:

public bool Contains(BoundingFrustum boundingFrustrum)
{
	return Bounds.Intersects(boundingFrustrum);
}

And finally, we're going to add a shortcut property that makes checking a bit less verbose.  Go ahead and add the following property to your QuadNode class:

public bool IsInView
{
	get { return Contains(_parentTree.ViewFrustrum); }
}

Our parent tree has a ViewFrustrum but it's not currently being updated so now we're going to see how it's done.

Constructing the View Frustrum

In Part 3 of this tutorial series we created our QuadTree class.  In the definition we included an internal property for the ViewFrustrum.  We want to change this definition a bit.  We want the ViewFrustrum to be readable by the public, however we don't want anything other than the QuadTree itself constructing the frustrum for now.  Go ahead and open your Quadtree.cs file.  Find the ViewFrustrum property we created earlier and replace it with the following:

public BoundingFrustum ViewFrustrum { get; private set; }

Next we need to add the code that constructs the initial BoundingFrustrum for the ViewFrustrum.  In your QuadTree constructor, immediately after setting the View and Projection properties, add the following:

//Initialize the bounding frustrum to be used for culling later.
ViewFrustrum = new BoundingFrustum(viewMatrix * projectionMatrix);

Now all we need to do is update our view frustrum before we update the rest of the tree.  To do this, we simply need to update the Matrix property of the BoundingFrustrum.  While still in our QuadTree.cs file, navigate to the Update method.  The first thing Update does is checks to see if the camera position has changed.  We'll be changing that later to make sure the terrain updates when the camera rotates as well.  For now, we're only interested in updating the matrix of the bounding frustrum.  This matrix is the product of the view and projection matrices.  Add the following line immediately after the camera position check:

ViewFrustrum.Matrix = View * Projection;

Our bounding frustrum will now be updated every time our terrain is updated.  This will allow the update process in the terrain to verify which geometry it needs to draw.

Splitting Only Visible Nodes

The first step in the process of culling away unwanted geometry is to only perform our splits selectively.  There are a couple of different ways we could approach this scenario.  One would be to go ahead and split all nodes every time an update is performed, but only if the camera position has changed.  In this scenario only the index buffer would need to be updated if the camera is rotating.  This would avoid the need to merge and resplit the tree and would drastically improve performance if the camera wasn't moving, however it would increase the number of splits necessary when the camera was moving and potentially degrade the performance. 

You could use a hybrid approach where you perform the splitting necessary depending on the type of camera you're using, for instance if you're on a fixed turret you could split everything and only update the buffer, but reduce the splitting to only visible nodes when actually moving across the terrain.

For our purposes, we're going to simply split only the visible nodes.  This adds a few extra checks against the ViewFrustrum but reduces the processing needed to perform the splits.  Open up your QuadNode.cs file again and navigate to the Split( ) method.  Add the following code to the very top of your split method as it should be the very first check to determine whether further processing is necessary.  This ensures that entire quadrants can be potentially eliminated from the splitting process depending on player location:

if (_parentTree.Cull && !IsInView)
	return;

As you can see, we're first checking against the Cull property of the parent.  If the parent is set to not cull, there's no sense in checking to see if the current quad node is in view.

Displaying Only Visible Nodes 

Now we're only splitting if the node is in view, however we're still going to draw the geometry that isn't visible to the player.  We want to make sure that we're not drawing that geometry either so go ahead and navigate to the SetActiveVertices( ) method.  We want to add the same line of code to the top of the SetActiveVertices method:

if (_parentTree.Cull && !IsInView)
	return;

And that's it.  Now that we've added our code to implement culling, geometry that is outside of the camera's view frustrum will not be displayed.  There may be pieces that are rendered however.  If at least part of a quad node is visible by the camera, that node needs to render.

Seeing Our Results

We've implemented culling, so how do we know if it's working?  Most of this work can be done from our Game1.cs, but first we need a way to determine the IndexCount for the current update iteration of the quad tree.  Go ahead and open your QuadTree.cs file and locate the IndexCount property.  This property is currently internal.  Change the IndexCount property definition to look like this:

public int IndexCount { get; private set; }

Now we have a way to read our IndexCount but only the QuadTree itself can update it.  Now open up your Game1.cs file and let's make some changes to our Game class.  The first thing we're going to do is change the initial position of the camera to give us a more ground level perspective of the terrain.  Navigate to the LoadContent( ) method and find the line that initializes the camera.  Replace it with the following:

_camera = new Camera(new Vector3(300, 50, 300), new Vector3(300, 0, 100), _device, 7000f);

The rest of the changes will all be made in the Update( ) method where we're going to add some basic front / back movement to our camera and display the number of rendered triangles in the title bar of our game as well as enable and disable culling.  First let's add a key to enable and disable culling.  We're going to use the "C" key for this.  Just above the line where we're setting the previous keyboard state to the current one, let's start adding the remainder of our key checks.  First add the following to check for enabling and disabling culling:

if (_previousKeyboardState.IsKeyDown(Keys.C) && !currentKeyboardState.IsKeyDown(Keys.C))
{
	_quadTree.Cull = !_quadTree.Cull;
}

Immediately below that, let's add a few lines to move our camera:

if (currentKeyboardState[Keys.Up] == KeyState.Down)
	_camera.Move(new Vector3(0, 0, -0.05f));
if (currentKeyboardState[Keys.Down] == KeyState.Down)
	_camera.Move(new Vector3(0, 0, 0.05f));

Now, scroll down a bit more and you'll see that we have _quadTree.Cull = true;.  Remove that line as we are setting the culling above with the "C" key.  And finally, just before we call base.Update(gameTime); we need to add a line of code to update the window title.  Here we're going to be displaying the number of triangles we're rendering.  Go ahead and add the following line:

Window.Title = String.Format("Triangles Rendered: {0} - Culling Enabled: {1}", _quadTree.IndexCount / 3, _quadTree.Cull);

And just in case you missed something, our entire Update method should now look like this:

		protected override void Update(GameTime gameTime)
		{
			var currentKeyboardState = Keyboard.GetState();

			// Allows the game to exit
			if (currentKeyboardState.IsKeyDown(Keys.Escape))
				Exit();

			if (_previousKeyboardState.IsKeyDown(Keys.W) && !currentKeyboardState.IsKeyDown(Keys.W))
			{
				if (_isWire)
				{
					_device.RasterizerState = _rsDefault;
					_isWire = false;
				}
				else
				{
					_device.RasterizerState = _rsWire;
					_isWire = true;
				}
			}

			if (_previousKeyboardState.IsKeyDown(Keys.C) && !currentKeyboardState.IsKeyDown(Keys.C))
			{
				_quadTree.Cull = !_quadTree.Cull;
			}

			if (currentKeyboardState[Keys.Up] == KeyState.Down)
				_camera.Move(new Vector3(0, 0, -0.05f));
			if (currentKeyboardState[Keys.Down] == KeyState.Down)
				_camera.Move(new Vector3(0, 0, 0.05f));



			_previousKeyboardState = currentKeyboardState;
			_quadTree.View = _camera.View;
			_quadTree.Projection = _camera.Projection;
			_quadTree.CameraPosition = _camera.Position;
			_quadTree.Update(gameTime);

			Window.Title = String.Format("Triangles Rendered: {0} - Culling Enabled: {1}", _quadTree.IndexCount / 3, _quadTree.Cull);
			base.Update(gameTime);
		}

The Results

Go ahead and run the project.  If you've used the same settings I have, you should see in the title bar that 466 triangles are being rendered and Culling is disabled.  If you use your up and down arrow keys you'll see the number of triangles rendered change as the LOD works its magic.  Press "W" to enter Wireframe mode and you can see the LOD working as the camera moves.  Finally, tap "C" on your keyboard to enable Culling.  Since we're only updating when the camera moves you won't see any changes yet.  Now just tap the up or down arrow once and you should see the number of triangles rendered drop significantly.

Note: There is a possible bug with my current implementation.  During testing I added an overhead camera for rendering and it appears that a small amount of geometry outside of the camera is being rendered.  This is the geometry immediately below the camera and some immediately behind the camera diagonally to the left and right.  This is only a small amount of extra geometry and we won't worry about it for now as a better view frustrum check would be more appropriate for production as discussed in earlier articles.

Conclusion

With culling in place we can start to do more to refine the mesh of our terrain without worrying about the impact it will have on GPU performance.  In the next section of the series I'm going to show you how to increase the LOD distance and scaling to reduce some of the visual popping you see.  We'll also implement a better camera to use for movement and modify the terrain updates so they update when camera rotation changes as well as position.  If you're enjoying this series so far or if you see any problems please leave a comment and let me know.  You're feedback is appreciated. 

 

<< Go back to Part 5                    Terrain Series Table of Contents                   Go to Part 7 >>