Last time, we talked about how to use materials to assign properties like color and specularity to an object. This will become important when we talk about meshes, next time. Another thing that we need to cover before we go there, though, is something called a z-buffer.
The problem that z-buffers address is that it is very difficult in a complex scene to tell what goes in front of what. To illustrate my point, consider a simple case: two triangles. Let’s make one of them red and close, and the other yellow and farther away. The code to set this up might look something like this:
protected void PopulateVertexBuffer(VertexBuffer vertices) { // Create two triangles CustomVertex.PositionNormalColored[] verts = (CustomVertex.PositionNormalColored[]) vertices.Lock(0, 0); int i = 0; verts[i++] = new CustomVertex.PositionNormalColored(0, 0.5f, 0, 0, 0, -1, Color.Red.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(0.3f, 0, 0, 0, 0, -1, Color.Red.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(-0.3f, 0, 0, 0, 0, -1, Color.Red.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(0, 0.5f, 2, 0, 0, -1, Color.Yellow.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(0.3f, 0, 2, 0, 0, -1, Color.Yellow.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(-0.3f, 0, 2, 0, 0, -1, Color.Yellow.ToArgb()); vertices.Unlock(); }
When we run the program, it looks something like this:
Which doesn’t look too weird until you realize that the triangles are the same size – the yellow one is just farther away. But it appears that it’s in front of the red triangle by virtue of the fact that it has been drawn over the red triangle.
One way to fix this problem is to reverse the order we draw the triangles in. If I were to simply trade the first three and last three vertices in the vertex buffer like so:
protected void PopulateVertexBuffer(VertexBuffer vertices) { // Create two triangles CustomVertex.PositionNormalColored[] verts = (CustomVertex.PositionNormalColored[]) vertices.Lock(0, 0); int i = 0; verts[i++] = new CustomVertex.PositionNormalColored(0, 0.5f, 2, 0, 0, -1, Color.Yellow.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(0.3f, 0, 2, 0, 0, -1, Color.Yellow.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(-0.3f, 0, 2, 0, 0, -1, Color.Yellow.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(0, 0.5f, 0, 0, 0, -1, Color.Red.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(0.3f, 0, 0, 0, 0, -1, Color.Red.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(-0.3f, 0, 0, 0, 0, -1, Color.Red.ToArgb()); vertices.Unlock(); }
Then we’d get something that looked much more reasonable, like this:
The reason this works is that the yellow triangle is drawn first, turning that set of pixels yellow, followed by the rendering of the red triangle, which simply overwrites any yellow pixels that were previously drawn.Direct3D isn’t doing any work right now to figure out which one is actually closer – it just draws them in the order we tell it to.
Manually ordering the triangles is a fairly reasonable approach if we only have two triangles, but as the scene grows more complex, or if the camera is moving, it quickly becomes intractable. When we talk about meshes, we’ll see that we don’t often even know how many triangles there are in the scene, or where they are relative to each other or the camera. And to add insult to injury, when triangles intersect each other, like this:
It becomes impossible to order the triangles correctly. No matter what you do, it’ll be wrong, because parts of the red triangle obscure the yellow one, and parts of the yellow triangle obscure the red one. To get this scene to draw correctly, I had to rely on something called a z-buffer.
A z-buffer is actually a pretty simple idea. The basic precept is to maintain a separate buffer the same size as the surface we’re drawing on. But instead of drawing colors on this surface like we would on a normal drawing surface, we draw depth values here (sometimes a z-buffer is called a depth buffer). Here’s the way it works.
As we draw triangles onto the normal drawing surface, we also draw the triangles onto the z-buffer – whatever pixels we touch in the main surface, we touch in the z-buffer. The difference is that instead of writing color values, we write a value that represents how far the pixel we’re drawing is from the camera. So the z-buffer maintains a sort of depth map of what’s already been drawn. The really useful part comes in when we try to draw something over something that’s already been drawn. What we do in that case is check the value in the z-buffer. If the value there is bigger than the value for the current pixel, it means that the thing we’re drawing is closer than the thing we’ve already drawn, so we can go ahead and draw the pixel into the drawing surface. If the value is smaller, then the thing we’re drawing is farther away than what we’ve already drawn, and we should not draw the pixel.
About the only extra requirement we have is that we have to initialize the z-buffer every time to some value that means “really far away”, so that the first time we try to draw over a particular pixel, we always decide that what we’re drawing is nearer than what’s already there. The way this works in Direct3D is that rather than actually using the distance from the camera at each pixel, we use a normalized value that falls in the range zero to one, with zero meaning “as close to the lens as you can get” and one meaning “as far away from the camera as you can get”. If you recall way back when we talked about coordinate systems, you might remember the clipping planes specified when we were defining the perspective transform:
Matrix projTxfm = Matrix.PerspectiveFovLH( (float)Math.PI/4.0F, // Standard field of view 1.0F, // Aspect ratio 1.0F, 10.0F // Clip objects > 10 // or < 1 unit from the camera );
Well, it turns out that these numbers aren’t just used to trim things more or less than a certain distance from the camera out of the scene – they’re also used to define the range of values in our z-buffer. In other words, for the call to Matrix.PerspectiveFovLH that I’ve shown here, any pixel that winds up 1.0 units from the camera will have a value of 0 in the z-buffer, and any pixel that winds up 10.0 units from the camera will have a value of 1 in the z-buffer.
This also illustrates why it’s so important to get the clipping planes as close to the closest/farthest objects in the scene. Because we’re using the clipping planes to map distance values onto the range zero to one, if we specify a nearer near clipping plane or farther far clipping plane than the nearest/farthest object in the scene, then we’re mapping the depth values in the scene onto a smaller range than the maximum available to us. This can lead to subtle rounding errors that can make objects that are very close to each other in depth get screwed up in the depth calculation. Rendering artifacts like things appearing to poke through solid objects can occur. You may have seen similar artifacts in games you’ve played, where a monsters arm or tentacle or whatever appears momentarily though a solid wall, giving away their location.
What’s great about the z-buffer system is that it works on a pixel-by-pixel basis; it still works when triangles intersect, and it doesn’t require us to figure out what order to render the triangles in. About the only drawback seems to be that we have to do this extra bookkeeping, writing once to the rendering buffer and once to the z-buffer. Fortunately, Direct3D actually takes care of the extra work for us automatically. All it requires is two slight modifications to our code.
The first thing we need to do is to ask Direct3D to create a z-buffer for us. We do this by setting a couple of flags on the PresentParameters object that we pass to the Device constructor in our setup code.
protected bool InitializeGraphics() { PresentParameters pres = new PresentParameters(); pres.Windowed = true; pres.SwapEffect = SwapEffect.Discard; // Tell Direct3D to create a ZBuffer for us pres.EnableAutoDepthStencil = true; pres.AutoDepthStencilFormat = DepthFormat.D16; device = new Device(0, DeviceType.Reference, this, CreateFlags.SoftwareVertexProcessing, pres); }
I’ve bolded the extra two lines of code we need. They’re pretty simple: the first tells Direct3D that we’d like it to automatically manage a z-buffer for us, and the second tells it what format to use for the z-buffer. There are several formats available, but DepthFormat.D16 specifies a 16-bit buffer format that should work well for most situations.
The only other thing we need to do is to make sure that we clear the z-buffer every frame so that all the pixels are set to “very far away”. Remember that the value for this is 1.0. You’ll also recall that we’ve already seen an API for clearing a buffer to a given value, starting way back when we talked about rendering: Device.Clear. All we have to do to get it to clear the z-buffer instead of the rendering target is to additionally specify the ClearFlags.ZBuffer flag, and to specify that we’d like to set the buffer to 1.0 everywhere. The call looks like this:
device.Clear(ClearFlags.Target | ClearFlags.ZBuffer, Color.Black, 1.0F, 0);
With these two changes in place, we now have everything we need to explore meshes, which we’ll discuss next time. A complete code example using z-buffers appears below.
Update
If you're using the October 2004 SDK, you'll need to change the call to Commit in SetupLightswith a call to Update. This is due to the changes the DirectX team made to the SDK in the October 2004 release.
using System; using System.Drawing; using System.Collections; using System.ComponentModel; using System.Windows.Forms; using System.Data; using Microsoft.DirectX; using Microsoft.DirectX.Direct3D; namespace Craig.DirectX.Direct3D { public class Game : System.Windows.Forms.Form { private Device device; private VertexBuffer vertices; static void Main() { Game app = new Game(); app.Width = 400; app.Height = 300; app.InitializeGraphics(); app.Show(); while (app.Created) { app.Render(); Application.DoEvents(); } app.DisposeGraphics(); } protected bool InitializeGraphics() { PresentParameters pres = new PresentParameters(); pres.Windowed = true; pres.SwapEffect = SwapEffect.Discard; // Tell Direct3D to create a ZBuffer for us pres.EnableAutoDepthStencil = true; pres.AutoDepthStencilFormat = DepthFormat.D16; device = new Device(0, DeviceType.Hardware, this, CreateFlags.SoftwareVertexProcessing, pres); device.RenderState.CullMode = Cull.None; vertices = CreateVertexBuffer(device); return true; } protected void OnCreateVertexBuffer(object o, EventArgs e) { PopulateVertexBuffer(vertices); } protected void PopulateVertexBuffer(VertexBuffer vertices) { // Create two triangles CustomVertex.PositionNormalColored[] verts = (CustomVertex.PositionNormalColored[]) vertices.Lock(0, 0); int i = 0; verts[i++] = new CustomVertex.PositionNormalColored(0, 0.5f, 0.1F, 1, 0, 0, Color.Yellow.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(0, 0, 0.4f, 1, 0, 0, Color.Yellow.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(0, 0, -0.2F, 1, 0, 0, Color.Yellow.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(0, 0.5f, 0, 0, 0, -1, Color.Red.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(0.3f, 0, 0, 0, 0, -1, Color.Red.ToArgb()); verts[i++] = new CustomVertex.PositionNormalColored(-0.3f, 0, 0, 0, 0, -1, Color.Red.ToArgb()); vertices.Unlock(); } protected VertexBuffer CreateVertexBuffer(Device device) { VertexBuffer buf = new VertexBuffer(typeof(CustomVertex.PositionNormalColored), 6, device, 0, CustomVertex.PositionNormalColored.Format, Pool.Default); PopulateVertexBuffer(buf); return buf; } protected void SetupMatrices() { float angle = Environment.TickCount / 2000.0F; device.Transform.World = Matrix.RotationY(angle); device.Transform.View = Matrix.LookAtLH(new Vector3(0.7f, 0.3F, -1.0F), new Vector3(0, 0.3F, 0), new Vector3(0, 1, 0)); device.Transform.Projection = Matrix.PerspectiveFovLH((float)Math.PI/4.0F, 1.0F, 1.0F, 10.0F); } protected void SetupLights() { device.RenderState.Lighting = true; device.Lights[0].Diffuse = Color.White; device.Lights[0].Specular = Color.White; device.Lights[0].Type = LightType.Directional; device.Lights[0].Direction = new Vector3(0, -1, 3); device.Lights[0].Commit(); device.Lights[0].Enabled = true; device.RenderState.Ambient = Color.FromArgb(0x20, 0x20, 0x20); device.RenderState.AmbientMaterialSource = ColorSource.Color1; } protected void Render() { // Clear the ZBuffer, too: important! device.Clear(ClearFlags.Target | ClearFlags.ZBuffer, Color.Black, 1.0F, 0); device.BeginScene(); SetupMatrices(); SetupLights(); device.VertexFormat = CustomVertex.PositionNormalColored.Format; device.SetStreamSource(0, vertices, 0); device.DrawPrimitives(PrimitiveType.TriangleList, 0, 2); device.EndScene(); device.Present(); } protected void DisposeGraphics() { vertices.Dispose(); device.Dispose(); } } }