|
||||
|
GameDev.net2D Rendering in DirectX 8(by Kelly Dempski)Background I have been reading alot 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 alot 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. Getting StartedAssuming 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 alot 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: void PostInitialize(float WindowWidth, float WindowHeight)— 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); … void Render2D()— This function is called each time you want to render your scene. Again, the Render function of the Tutorial now looks like this: VOID Render() { if (NULL == g_pd3dDevice) return; // 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 g_pd3dDevice->BeginScene(); Render2D(); //My added line… // End the scene g_pd3dDevice->EndScene(); // Present the backbuffer contents to the display g_pd3dDevice->Present(NULL, NULL, NULL, NULL); } OK, that's our shell of an application. Now for the good stuff… Setting Up for 2D drawing in D3DNOTE: 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Å 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 D3DXMatrixOrthoLHand 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 D3DXMatrixIdentity. To use the D3DX functions, we need to add: #include <d3dx8.h> and add d3dx8dt.lib to the list of linked libraries. Once that was set up, the PostInitialize function now looks like this: void PostInitialize(float WindowWidth, float WindowHeight) { D3DXMATRIX Ortho2D; D3DXMATRIX Identity; D3DXMatrixOrthoLH(&Ortho2D, WindowWidth, WindowHeight, 0.0f, 1.0f); D3DXMatrixIdentity(&Identity); g_pd3dDevice->SetTransform(D3DTS_PROJECTION, &Ortho2D); g_pd3dDevice->SetTransform(D3DTS_WORLD, &Identity); g_pd3dDevice->SetTransform(D3DTS_VIEW, &Identity); } 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Å Setting Up a 2D "Panel" 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: struct PANELVERTEX { FLOAT x, y, z; DWORD color; FLOAT u, v; }; #define D3DFVF_PANELVERTEX (D3DFVF_XYZ | D3DFVF_DIFFUSE | D3DFVF_TEX1) 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. LPDIRECT3DVERTEXBUFFER8 g_pVertices = NULL; Now, add the following lines of code to the PostInitialize function (explanation to follow): 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[0].color = pVertices[1].color = pVertices[2].color = pVertices[3].color = 0xffffffff; //Set positions and texture coordinates pVertices[0].x = pVertices[3].x = -PanelWidth / 2.0f; pVertices[1].x = pVertices[2].x = PanelWidth / 2.0f; pVertices[0].y = pVertices[1].y = PanelHeight / 2.0f; pVertices[2].y = pVertices[3].y = -PanelHeight / 2.0f; pVertices[0].z = pVertices[1].z = pVertices[2].z = pVertices[3].z = 1.0f; pVertices[1].u = pVertices[2].u = 1.0f; pVertices[0].u = pVertices[3].u = 0.0f; pVertices[0].v = pVertices[1].v = 0.0f; pVertices[2].v = pVertices[3].v = 1.0f; g_pVertices->Unlock(); 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… Drawing the PanelDrawing the rectangle is pretty easy. Add the following lines of code to your Render2D function: g_pd3dDevice->SetVertexShader(D3DFVF_PANELVERTEX); g_pd3dDevice->SetStreamSource(0, g_pVertices, sizeof(PANELVERTEX)); g_pd3dDevice->DrawPrimitive(D3DPT_TRIANGLEFAN, 0, 2); 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: g_pd3dDevice->SetRenderState(D3DRS_LIGHTING, FALSE); 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. Texturing the Panel 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: LPDIRECT3DTEXTURE8 g_pTexture = NULL; This is the texture object we'll be using. To load a texture from a file, add this line to PostInitialize: D3DXCreateTextureFromFileEx(g_pd3dDevice, [Some Image File], 0, 0, 0, 0, D3DFMT_A8R8G8B8, D3DPOOL_MANAGED, D3DX_DEFAULT, D3DX_DEFAULT , 0, NULL, NULL, &g_pTexture); 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: g_pd3dDevice->SetTexture(0, g_pTexture); 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 64×64, 128×512, 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. Texture CoordinatesNote 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 640×480 window, you could place the 640×480 image in a 1024×512 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. TransparencyAs 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->SetRenderState(D3DRS_ALPHABLENDENABLE, TRUE); g_pd3dDevice->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA); g_pd3dDevice->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA); g_pd3dDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_MODULATE); 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. Moving the PanelBy 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: D3DXMATRIX Position; D3DXMatrixTranslation(&Position, 50.0f, 0.0f, 0.0f); g_pd3dDevice->SetTransform(D3DTS_WORLD, &Position); 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. Other Matrix OperationsThere 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: D3DXMATRIX M = M1 * M2 * M3 * M4; g_pd3dDevice->SetTransform(D3DTS_WORLD, &M); 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. Wrapping UpLooking 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. DirectDraw The Easy Way(by Johnny Watson) This article describes how to setup DirectDraw displays and surfaces with minimal effort using the common libs included in the DirectX SDK. This can be particularly helpful for those who want a quick way of doing things, while still maintining control of their application's basic framework. Please note the fact that these classes abstract quite a few things, and I highly recommend peering into their functions at some point to see how things are done on a lower level. Setting it UpFor this article, I'm assuming you have Microsoft Visual C++, and the DirectX 8.1 SDK. If not, please adapt to portions of this article accordingly. Anyway, start up Visual C++, and create a new Win32 Application project. Then go into your DirectX SDK's samples\multimedia\common\include directory, and copy dxutil.h and ddutil.h to your project folder. Then go to samples\multimedia\common\src, and do the same with dxutil.cpp, and ddutil.cpp. Add the four files to your project, and link to the following libraries: dxguid.lib, ddraw.lib, winmm.lib. Now, you create a new C++ source file document, and add it to your project as well. This will be the file we'll work with throughout the tutorial. The CodeNow that we've got that out of the way, we can get down and dirty with some actual code! Let's start off with this: #define WIN32_LEAN_AND_MEAN #include <windows.h> #include "dxutil.h" #include "ddutil.h" Standard procedure here. We #define WIN32_LEAN_AND_MEAN, so all that icky MFC stuff doesn't bloat our program (just a good practice if you aren't using MFC). Then we include dxutil.h, and ddutil.h, the two centerpieces of this article. They provide shortcut classes to make programming with DirectX in general less taxing. //globals bool g_bActive = false; CDisplay *g_pDisplay = NULL; CSurface *g_pText = NULL; //function prototypes bool InitDD(HWND); void CleanUp(); void GameLoop(); Pretty self explanatory. Our first global, g_bActive, is a flag to let our application know whether or not it's okay to run the game. If we didn't have this flag, our application could potentially attempt to draw something onto our DirectDraw surface after it's been destroyed. While this is usually a problem at the end of the program where it isn't that big of a deal, it generates an illegal operation error, and we don't want that, now do we? g_pDisplay is our display object. CDisplay is the main class in ddutil. It holds our front and back buffers, as well as functionality for accessing them, drawing surfaces onto them, creating surfaces, etc. g_pText is our text surface. We will draw text onto this surface (as you've probably figured out), and blit it onto our screen. Note how both objects are pointers, and are initialized to NULL. Now for the function prototypes. InitDD() simply initializes DirectDraw. Thanks to the DDraw Common Files, this is a fairly simple procedure (but we'll get to that later). CleanUp() calls the destructor to our g_pDisplay object, which essentially shuts down DDraw, and cleans up all of our surfaces. GameLoop() is obviously where you'd put your game. LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch(uMsg) { case WM_CREATE: InitDD(hWnd); g_bActive=true; break; case WM_CLOSE: g_bActive=false; CleanUp(); DestroyWindow(hWnd); break; case WM_DESTROY: PostQuitMessage(0); break; case WM_MOVE: g_pDisplay->UpdateBounds(); break; case WM_SIZE: g_pDisplay->UpdateBounds(); break; default: return DefWindowProc(hWnd, uMsg, wParam, lParam); break; } return 0; } Standard Windows Procedure function here. On the WM_CREATE event, we initialize DirectDraw, and set our g_bActive flag to true, so our GameLoop() function is executed. When WM_CLOSE is called, we want to set our active flag to false (again, so our app doesn't try to draw to our DDraw screen after its been destroyed), then call our CleanUp() function, and finally destroy our window. It's important that you handle the WM_MOVE and WM_SIZE events, because otherwise DirectDraw will not know the window has been moved or resized and will continue drawing in the same position on your screen, in spite of where on the screen the window has moved. bool InitDD(HWND hWnd) { //dd init code g_pDisplay = new CDisplay(); if (FAILED(g_pDisplay->CreateWindowedDisplay(hWnd, 640, 480))) { MessageBox(NULL, "Failed to Initialize DirectDraw", "DirectDraw Initialization Failure", MB_OK | MB_ICONERROR); return false; } return true; } The infamous InitDD() function… but wait, it's only several lines! This is what the common libs were made for! We now have all the DirectDraw setup garbage out of the way, effortlessly! Again, you'll notice that it's done a lot for you. If you don't really care to know the exact procedure of what has been abstracted from you, at least get the gist of it. It will help out if you have to go back and change the cooperative level or whatnot. Note that this is a boolean function, so if you like, you can do error checking (which I, for some reason or another, decided to omit in this article). void CleanUp() { SAFE_DELETE(g_pDisplay); } Simple enough. This function calls on the SAFE_DELETE macro defined in dxutil to delete our display object, and call the destructor. void MainLoop() { g_pDisplay->CreateSurfaceFromText(&g_pText, NULL, "DDraw using Common Files", RGB(0,0,0), RGB(0,255,0)); g_pDisplay->Clear(0); g_pDisplay->Blt(0, 0, g_pText, 0); g_pDisplay->Present(); g_pText->Destroy(); } This is where you'd want to put your game. In order to give you an example of how surface objects work, we've made a simple text surface and drawn some text onto it. Note that we destroy g_pText at the end, because it is recreated every cycle and not doing so would eventually eat up quite a bit of memory. int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int iShowCmd) { WNDCLASSEX wc; HWND hWnd; MSG lpMsg; wc.cbClsExtra=0; wc.cbSize=sizeof(WNDCLASSEX); wc.cbWndExtra=0; wc.hbrBackground=(HBRUSH)GetStockObject(BLACK_BRUSH); wc.hCursor=LoadCursor(NULL, IDC_ARROW); wc.hIcon=LoadIcon(NULL, IDI_APPLICATION); wc.hIconSm=LoadIcon(NULL, IDI_APPLICATION); wc.hInstance=hInstance; wc.lpfnWndProc=WndProc; wc.lpszClassName="wc"; wc.lpszMenuName=0; wc.style=CS_HREDRAW | CS_VREDRAW; if (!RegisterClassEx(&wc)) { MessageBox(NULL, "Couldn't Register Window Class", "Window Class Registration Failure", MB_OK | MB_ICONERROR); return 0; } hWnd = CreateWindowEx(NULL, "wc", "DirectDraw Common Files in Action", WS_POPUPWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 640, 480, 0, 0, hInstance, 0); if (hWnd == NULL) { MessageBox(NULL, "Failed to Create Window", "Window Creation Failure", MB_OK | MB_ICONERROR); return 0; } ShowWindow(hWnd, SW_SHOW); UpdateWindow(hWnd); while (lpMsg.message != WM_QUIT) { if(PeekMessage(&lpMsg, 0, 0, 0, PM_REMOVE)) { TranslateMessage(&lpMsg); DispatchMessage(&lpMsg); } else if(g_bActive) { MainLoop(); } } return lpMsg.wParam; } The longest function in our application, WinMain(). As usual, we setup our window class, create our window, show and update it, and go into the message loop. The loop is different from usual because we dont want the processing of our game to interfere with the processing of messages. We use our g_bActive flag to see if its safe to call our game loop, which involves blitting things onto the screen, and at the end of it all we finally return lpMsg.wParam (I'm honestly not sure why, but thats how it's done in every other Win32 app, so oh well). Pretty simple, huh? 135 lines of code and we're already blitting stuff to the screen. Feel free to explore these classes further, and experiment with things like color keying, loading surfaces from bitmaps, ect. This is a very cool shortcut to using DDraw. It makes things easy without sacrificing control (you can always go back and edit the classes if you want) or performance. One thing to note is that on my computer if I don't draw anything onto the screen using this framework, the application will lock up (which is why I've included text output here). This shouldn't be much of an issue, seeing as how your game will more than likely be blitting things onto the screen (unless there's some new art style which I'm not aware of). Have fun! |
|
||
Ãëàâíàÿ |  èçáðàííîå | Íàø E-MAIL | Ïðèñëàòü ìàòåðèàë | Íàø¸ë îøèáêó | Íàâåðõ |
||||
|