I have been reading a lot of questions lately related to DirectX 8 and the exclusion of DirectDraw from the new API. Many people have fallen back to DX7. I can understand people using DX7 if they have a lot of experience with that API, but many of the questions seem to be coming from people who are just learning DX, and they are stuck learning an old API. People have argued that many people don't have 3D hardware, and therefore D3D would be a bad alternative for DirectDraw. I don't believe this is true - 2D rendering in D3D requires very little vertex manipulation, and everything else boils down to fillrate. In short, 2D rendering in D3D on 2D hardware should have pretty much the same performance as DirectDraw, assuming decent fillrate. The advantage is that the programmer can learn the newest API, and performance on newer hardware should be very good. This article will present a framework for 2D rendering in DX8 to ease the transition from DirectDraw to Direct3D. In each section, you may see things that you don't like ("I'm a 2D programmer, I don't care about vertices!"). Rest assured, if you implement this simple framework once, you'll never think about these things again.
Assuming you have the DX8 SDK, there are a couple tutorials that present how to create a D3D device and set up a render loop, so I don't want to spend a lot of time on that. For the purposes of this article, I'll talk about the tutorial found in [DX8SDK]\samples\Multimedia\Direct3D\Tutorials\Tut01_CreateDevice, although you can add it to anything. To that sample, I'll add the following functions:
[font="Courier New"][color="#000080"]void PostInitialize(float WindowWidth, float WindowHeight)[/color][/font] - this function is called by the app after everything else is set up. You've created your device and initialized everything. If you're following along with the Tutorial code, WinMain looks like this:
if( SUCCEEDED( InitD3D( hWnd ) ) )
PostInitialize(200.0f, 200.0f); // This is my added line. The values of
// 200.0f were chosen based on the sizes
// used in the call to CreateWindow.
ShowWindow( hWnd, SW_SHOWDEFAULT );
...[/code][/indent] [font="Courier New"][color="#000080"]void Render2D()[/color][/font] - This function is called each time you want to render your scene. Again, the Render function of the Tutorial now looks like this:
if( NULL == g_pd3dDevice )
// Clear the backbuffer to a blue color
g_pd3dDevice->Clear( 0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(0,0,255), 1.0f, 0 );
// Begin the scene
Render2D(); //My added line...
// End the scene
// Present the backbuffer contents to the display
g_pd3dDevice->Present( NULL, NULL, NULL, NULL );
}[/code][/indent] OK, that's our shell of an application. Now for the good stuff...
[size="5"][b]Setting Up for 2D drawing in D3D[/b][/size]
NOTE: This is where we start talking about some of the nasty math involved with D3D. Don't be alarmed - if you want to, you can choose to ignore most of the details...[/i] Most Direct3D drawing is controlled by three matrices: the projection matrix, the world matrix, and the view matrix. The first one we'll talk about is the projection matrix. You can think of the projection matrix as defining the properties of the lens of your camera. In 3D applications, it defines things like perspective, etc. But, we don't want perspective - we are talking about 2D!! So, we can talk about orthogonal projections. To make a long story very short, this allows us to draw in 2D without all the added properties of 3D drawing. To create an orthogonal matrix, we need to call [font="Courier New"][color="#000080"]D3DXMatrixOrthoLH[/color][/font] and this will create a matrix for us. The other matrices (view and world) define the position of the camera and the position of the world (or an object in the world). For our 2D drawing, we don't need to move the camera, and we don't want to move the world for now, so we'll use an identity matrix, which basically sets the camera and world in a default position. We can create identity matrices with [font="Courier New"][color="#000080"]D3DXMatrixIdentity[/color][/font]. To use the D3DX functions, we need to add:
[indent][code]void PostInitialize(float WindowWidth, float WindowHeight)
D3DXMatrixOrthoLH(&Ortho2D, WindowWidth, WindowHeight, 0.0f, 1.0f);
}[/code][/indent] We are now set up for 2D drawing, now we need something to draw. The way things are set up, our drawing area goes from -WindowWidth/2 to WindowWidth/2 and -WindowHeight/2 to WindowHeight/2. One thing to note, in this code, the width and the height are being specified in pixels. This allows us to think about everything in terms of pixels, but we could have set the width and height to say 1.0 and that would have allowed us to specify sizes, etc. in terms of percentages of the screen space, which would be nice for supporting multiple resolutions easily. Changing the matrix allows for all sorts of neat things, but for simplicity, we'll talk about pixels for now...
[size="5"][b]Setting Up a 2D "Panel"[/b][/size]
When I draw in 2D, I have a class called CDX8Panel that encapsulates everything I need to draw a 2D rectangle. For simplicity, and it avoid a C++ explanation, I have pulled out the code here. However, as we build up our code to draw a panel, you'll probably see the value of such a class or higher level API if you don't use C++. Also, we are about to recreate much that goes on in the ID3DXSprite interface. I'm explaining the basics here to show the way things work, but you may want to use the sprite interface if it suits your needs.
My definition of a panel is simply a 2D textured rectangle that we are going to draw on the screen. Drawing a panel will be extremely similar to a 2D blit. Experienced 2D programmers may think that this is a lot of work for a blit, but that work pays off with the amount of special effects that it enables. First, we have to think about the geometry of our rectangle. This involves thinking about vertices. If you have 3D hardware, the hardware will process these vertices extremely quickly. If you have 2D hardware, we are talking about so few vertices that they will be processed very quickly by the CPU. First, let's define our vertex format. Place the following code near the #includes:
FLOAT x, y, z;
FLOAT u, v;
#define D3DFVF_PANELVERTEX (D3DFVF_XYZ | D3DFVF_DIFFUSE | D3DFVF_TEX1)[/code][/indent] This structure and Flexible Vertex Format (FVF) specify that we are talking about a vertex that has a position, a color, and a set of texture coordinates.
Now we need a vertex buffer. Add the following line of code to the list of globals. Again, for simplicity, I'm making it global - this is not a demonstration of good coding practice.
[indent][code]LPDIRECT3DVERTEXBUFFER8 g_pVertices = NULL;[/code][/indent] Now, add the following lines of code to the PostInitialize function (explanation to follow):
[indent][code]float PanelWidth = 50.0f;
float PanelHeight = 100.0f;
g_pd3dDevice->CreateVertexBuffer(4 * sizeof(PANELVERTEX), D3DUSAGE_WRITEONLY,
D3DFVF_PANELVERTEX, D3DPOOL_MANAGED, &g_pVertices);
PANELVERTEX* pVertices = NULL;
g_pVertices->Lock(0, 4 * sizeof(PANELVERTEX), (BYTE**)&pVertices, 0);
//Set all the colors to white
pVertices.color = pVertices.color = pVertices.color = pVertices.color = 0xffffffff;
//Set positions and texture coordinates
pVertices.x = pVertices.x = -PanelWidth / 2.0f;
pVertices.x = pVertices.x = PanelWidth / 2.0f;
pVertices.y = pVertices.y = PanelHeight / 2.0f;
pVertices.y = pVertices.y = -PanelHeight / 2.0f;
pVertices.z = pVertices.z = pVertices.z = pVertices.z = 1.0f;
pVertices.u = pVertices.u = 1.0f;
pVertices.u = pVertices.u = 0.0f;
pVertices.v = pVertices.v = 0.0f;
pVertices.v = pVertices.v = 1.0f;
g_pVertices->Unlock();[/code][/indent] This is actually much simpler than it may look. First, I made up a size for the panel just so we'd have something to work with. Next, I asked the device to create a vertex buffer that contained enough memory for four vertices of my format. Then I locked the buffer so I could set the values. One thing to note, locking buffers is very expensive, so I'm only going to do it once. We can manipulate the vertices without locking, but we'll discuss that later. For this example I have set the four points centered on the (0, 0). Keep this in the back of your mind; it will have ramifications later. Also, I set the texture coordinates. The SDK explains these pretty well, so I won't get into that. The short story is that we are set up to draw the entire texture. So, now we have a rectangle set up. The next step is to draw it...
[size="5"][b]Drawing the Panel[/b][/size]
Drawing the rectangle is pretty easy. Add the following lines of code to your Render2D function:
g_pd3dDevice->SetStreamSource(0, g_pVertices, sizeof(PANELVERTEX));
g_pd3dDevice->DrawPrimitive(D3DPT_TRIANGLEFAN, 0, 2);[/code][/indent] These lines tell the device how the vertices are formatted, which vertices to use, and how to use them. I have chosen to draw this as a triangle fan, because it's more compact than drawing two triangles. Note that since we are not dealing with other vertex formats or other vertex buffers, we could have moved the first two lines to our PostInitialize function. I put them here to stress that you have to tell the device what it's dealing with. If you don't, it may assume that the vertices are a different format and cause a crash. At this point, you can compile and run the code. If everything is correct, you should see a black rectangle on a blue background. This isn't quite right because we set the vertex colors to white. The problem is that the device has lighting enabled, which we don't need. Turn lighting off by adding this line to the PostInitialize function:
[indent][code]g_pd3dDevice->SetRenderState(D3DRS_LIGHTING, FALSE);[/code][/indent] Now, recompile and the device will use the vertex colors. If you'd like, you can change the vertex colors and see the effect. So far, so good, but a game that features a white rectangle is visually boring, and we haven't gotten to the idea of blitting a bitmap yet. So, we have to add a texture.
[size="5"][b]Texturing the Panel[/b][/size]
A texture is basically a bitmap that can be loaded from a file or generated from data. For simplicity, we'll just use files. Add the following to your global variables:
[indent][code]LPDIRECT3DTEXTURE8 g_pTexture = NULL;[/code][/indent] This is the texture object we'll be using. To load a texture from a file, add this line to PostInitialize:
[indent][code]D3DXCreateTextureFromFileEx(g_pd3dDevice, [Some Image File], 0, 0, 0, 0,
D3DFMT_A8R8G8B8, D3DPOOL_MANAGED, D3DX_DEFAULT,
D3DX_DEFAULT , 0, NULL, NULL, &g_pTexture);[/code][/indent] Replace [Some Image File] with a file of your choice. The D3DX function can load many standard formats. The pixel format we're using has an alpha channel, so we could load a format that has an alpha channel such as .dds. Also, I'm ignoring the ColorKey parameter, but you could specify a color key for transparency. I'll get back to transparency in a little bit. For now, we have a texture and we've loaded an image. Now we have to tell the device to use it. Add the following line to the beginning of Render2D:
[indent][code]g_pd3dDevice->SetTexture(0, g_pTexture);[/code][/indent] This tells the device to render the triangles using the texture. One important thing to remember here is that I am not adding error checking for simplicity. You should probably add error checking to make sure the texture is actually loaded before attempting to use it. One possible error is that for a lot of hardware, the textures must have dimensions that are powers of 2 such as 64x64, 128x512, etc. This constraint is no longer true on the latest nVidia hardware, but to be safe, use powers of 2. This limitation bothers a lot of people, so I'll tell you how to work around it in a moment. For now, compile and run and you should see your image mapped onto the rectangle.
Note that it is stretched/squashed to fit the rectangle. You can adjust that by adjusting the texture coordinates. For example, if you change the lines where u = 1.0 to u = 0.5, then only half of the texture is used and the remaining part will not be squashed. So, if you had a 640x480 image that you wanted to place on a 640x480 window, you could place the 640x480 image in a 1024x512 texture and specify 0.625, 0.9375 for the texture coordinates. You could use the remaining parts of the texture to hold other sub images that are mapped to other panels (through the appropriate texture coordinates). In general, you want to optimize the way textures are used because they are eating up graphics memory and/or moving across the bus. This may seem like a lot of work for a blit, but it has a lot to do with the way new cards are optimized for 3D (like it or not). Besides, putting some thought into how you are moving large chunks of memory around the system is never a bad idea. But I'll get off my soapbox.
Let's see where we are so far. At one level, we've written a lot of code to blit a simple bitmap. But, hopefully you can see some of the benefit and the opportunities for tweaking. For instance, the texture coordinates automatically scale the image to the area we've defined by the geometry. There are lots of things this does for us, but consider the following. If we had set up our ortho matrix to use a percentage based mapping, and we specified a panel as occupying the lower quarter of the screen (for a UI, let's say), and we specified a texture with the correct texture coordinates, then our UI would automagically be drawn correctly for any chosen window/screen size. Not exactly cold fusion, but it's one of many examples. Now that we have the texture working well, we have to get back to talking about transparency.
As I said before, one easy way of adding transparency is to specify a color key value in the call to D3DXCreateTextureFromFileEx. Another is to use an image that actually has an alpha channel. Either way, specify a texture with some transparency (either with an alpha channel or a color key value) and run the app. You should see no difference. This is because alpha blending is not enabled. To enable alpha blending, add these lines to PostInitialize:
g_pd3dDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_MODULATE);[/code][/indent] The first line enables blending. The next two specify how the blending works. There are many possibilities, but this is the most basic type. The last line sets things up so that changing the alpha component of the vertex colors will fade the entire panel by scaling the texture values. For a more in depth discussion of the available settings, see the SDK. Once these lines are in place, you should see the proper transparency. Try changing the colors of the vertices to see how they affect the panel.
[size="5"][b]Moving the Panel[/b][/size]
By now our panel has many of the visual properties we need, but it's still stuck in the center of our viewport. For a game, you probably want things to move. One obvious way is to relock the vertices and change their positions. DO NOT do this!! Locking is very expensive, involves moving data around, and is unnecessary. A better way is to specify a world transformation matrix to move the points. For many people, matrices may seem a bit scary, but there are a host of D3DX functions that make matrices very easy. For example, to move the panel, add the following code to the beginning of Render2D:
D3DXMatrixTranslation(&Position, 50.0f, 0.0f, 0.0f);
g_pd3dDevice->SetTransform(D3DTS_WORLD, &Position);[/code][/indent] This creates a matrix that moves the panel 50 pixels in the X direction and tells the device to apply that transform. This could be wrapped into a function like MoveTo(X, Y), which I won't actually give the code for. Earlier I said to remember the fact that the vertex code specified the vertices around the origin. Because we did that, translating (moving) the position moves the center of the panel. If you are more comfortable with moving the upper left or some other corner, change the way the vertices are specified. You could also create different coordinate systems by correcting the parameters sent to the MoveTo function. For example, our viewport currently goes from -100 to +100. If I wanted to use MoveTo as if it were going from 0 to 200, I could simply correct for it in my call to D3DXMatrixTranslation by subtracting 100 from the X position. There are many ways to quickly change this to meet your specific needs, but this will provide a good basis for experimentation.
[size="5"][b]Other Matrix Operations[/b][/size]
There are many other matrix operations that will affect the panel. Perhaps the most interesting are scaling and rotation. There are D3DX functions for creating these matrices as well. I'll leave the experimenting up to you, but here are a few hints. Rotation about the Z-axis will create rotation on the screen. Rotating about X and Y will look like you are shrinking Y and X. Also, the way you apply multiple operations is through multiplication and then sending the resulting matrix to the device:
[indent][code]D3DXMATRIX M = M1 * M2 * M3 * M4;
g_pd3dDevice->SetTransform(D3DTS_WORLD, &M);[/code][/indent] But, remember that the product of matrix multiplication is dependent on the order of the operands. For instance, Rotation * Position will move the panel and then rotate it. Position * Rotation will cause an orbiting effect. If you string together several matrices and get unexpected results, look closely at the order.
As you become more comfortable, you may want to experiment with things like the texture matrix, which will allow you to transform the texture coordinates. You could also move the view matrix to affect your coordinate system. One thing to remember: locks are very costly, always look to things like matrices before locking your vertex buffers.
Looking at all the code listed here, this is really a long, drawn out way to do a blit, but the nice thing is that most of this can be wrapped into tidy functions or classes that make all this a one time cost for long term benefit. Please remember that this is presented in a very bare bones, unoptimized way. There are many ways to package this to get maximum benefit. This method should be the optimal way to create 2D applications on current and coming hardware and also will pay off in terms of the effects that you can implement very easily on top of it. This approach will also help you blend 2D with 3D because, aside from some matrices, the two are the same. The code was easily adapted from 2D work that I did in OpenGL, so you could even write abstract wrappers around the approach to support both APIs. My hope is that this will get people started using DX8 for 2D work. Perhaps in future articles I will talk about more tricks and effects.