Advanced 2D Graphics With DirectX8 by (10 July 2001) | Return to The Archives

Introduction

Many of us have been programming in DirectDraw for a while. We are used to locking our surfaces, writing out our screens, and flipping our buffers. We all have code for how to blit a sprite, and how to handle the surfaces. However, DirectX8 changes all that. While you can still count on backwards compatibility with DX7, you will be short-changing yourself on the improvements gained by moving to 3D (blit speed, alpha blending, stretching, etc.) .
This sample, called D2D, will show how you can still use 2D coding techniques in the 3D world, and how you can gain a speed increase over standard 2D DirectDraw code.
There are really only two basic parts to 2D programming. Rendering a bitmap, and writing directly to a surface (for lines, circles, etc.) This tutorial will show how to do that.

New Objects

To render a 2D world, you only need a few object types (I’m using c++ here, because it’s easier to show encapsulation). A D3D interface object, a simple polygon (assumed to be a rectangle), and a texture to apply to that polygon. This sample will create a windowed program that simply displays a bitmap. The effect is not exactly stunning, but the process will be well worth the effort.

    class D3DObj
    {
    public:
    	D3DObj() : m_pD3D(NULL), m_pd3dDevice(NULL) { Clear(); }
    	~D3DObj() { Clear(); }
    	void Init( HWND hWnd);
    	void Clear();
    	int Render();

    	LPDIRECT3D8				m_pD3D;
    	LPDIRECT3DDEVICE8			m_pd3dDevice;
    }

The D3D object has only two data members, a LPDIRECT3D8 and a LPDIRECT3DDEVICE8. While it is usually preferable to have a constructor provide the data initialization, this code will only clear out those data members. Instead, there is an Init function that provides the initialization of those data members, and receives the HWND of the rendering window.

    void D3DObj::Clear()
    {
    	SAFE_RELEASE( m_pd3dDevice);
    	SAFE_RELEASE( m_pD3D);
    }

The Clear function just releases the pointers, and sets them to NULL.

    void D3DObj::Init( HWND hWnd)
    {
    	// Part one, Create the main D3D Object.
    	m_pD3D = Direct3DCreate8( D3D_SDK_VERSION );

    	// Part two, create the rendering device (which will be compliant with
            // the current screen pixeldata)

    	D3DDISPLAYMODE d3ddm;
    	m_pD3D->GetAdapterDisplayMode( D3DADAPTER_DEFAULT, &d3ddm );

    	D3DPRESENT_PARAMETERS d3dpp;
    	ZeroMemory( &d3dpp, sizeof(d3dpp) );
    	d3dpp.Windowed   = TRUE;
    	d3dpp.SwapEffect = D3DSWAPEFFECT_DISCARD;
    	d3dpp.BackBufferFormat = d3ddm.Format;
    	d3dpp.EnableAutoDepthStencil = TRUE;
    	d3dpp.AutoDepthStencilFormat = D3DFMT_D16;

    	m_pD3D->CreateDevice( D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd,
    	                      D3DCREATE_SOFTWARE_VERTEXPROCESSING, &d3dpp, &m_pd3dDevice );

    	// Part three, set the render flags.
    	m_pd3dDevice->SetRenderState ( D3DRS_CULLMODE, D3DCULL_NONE);
    	m_pd3dDevice->SetRenderState ( D3DRS_LIGHTING, FALSE);
    	m_pd3dDevice->SetRenderState ( D3DRS_ZENABLE, TRUE);

    	// Set diffuse blending for alpha set in vertices.
    	m_pd3dDevice->SetRenderState( D3DRS_ALPHABLENDENABLE,   TRUE );
    	m_pd3dDevice->SetRenderState( D3DRS_SRCBLEND,  D3DBLEND_SRCALPHA );
    	m_pd3dDevice->SetRenderState( D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA );

    	m_pd3dDevice->SetTextureStageState( 0, D3DTSS_COLOROP,   D3DTOP_MODULATE);
    	m_pd3dDevice->SetTextureStageState( 0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
    	m_pd3dDevice->SetTextureStageState( 0, D3DTSS_COLORARG2, D3DTA_DIFFUSE);
    	m_pd3dDevice->SetTextureStageState( 0, D3DTSS_ALPHAOP,  D3DTOP_SELECTARG1);

    	m_pd3dDevice->SetVertexShader( D3DFVF_CUSTOMVERTEX );
    }

The code in the Init function is fairly straightforward. It should be noted that all of those functions have return values, and should be handled in proper debug fashion. The only reason it is not handled here is because this is sample code.
First, the main D3D object is created, and then the default display mode is examined (this sample works well in 16, 24, and 32-bit color, but not in 8-bit palletized mode). Using the default display mode, the Init function will create a device that utilizes the HWND of the main window. Next, the code sets a series of flags that tell how to render. These flags are well documented in the DX8 SDK, and other flipcode tutorials.
Notice that the Z buffer is enabled for this sample. It might seem to make more sense to disable the Z-buffer, however, when tiling bitmaps inside a program (for dialog boxes, target reticules, etc.) it is a great comfort to be able to set a z value, rather than resort the render list. This is, of course, purely a matter of personal preference.
Since this program will provide the vertex coordinates and colors on its own, there is no need for projection or culling (all objects will be facing the screen). Since the texture will be providing its own color data, there is no need for lighting (although this could be a cool special effect if applied properly). Alpha blending is enabled, and the texture is set to provide the alpha blending parameters. Also, vertex shader is set to recognize the vertex type that is used.

    int D3DObj::Render()
    {
    	m_pd3dDevice->Clear( 0, NULL, D3DCLEAR_TARGET|D3DCLEAR_ZBUFFER,
    		             D3DCOLOR_XRGB(0,0,255), 1.0f, 0L );
    	m_pd3dDevice->BeginScene();

    	// Rendering of scene objects happens here.

    	m_pd3dDevice->SetTexture( 0, MyRect.m_Texture.pTexture );
    	m_pd3dDevice->SetStreamSource( 0, MyRect.m_VertexBuffer, sizeof(CUSTOMVERTEX) );
    	m_pd3dDevice->DrawPrimitive( D3DPT_TRIANGLESTRIP , 0, 2 );

    	// End the scene.
    	m_pd3dDevice->EndScene();

    	m_pd3dDevice->Present( NULL, NULL, NULL, NULL );
    	return 0;
    }

This is the only other function in the D3Dobj definition. This is the function that actually does all the work; for a 2D programmer, this is the equivalent to the DirectDraw Flip function.
First, all buffers are cleared (note, even though this is a 2D program, the Z buffer is still cleared) and the screen is filled with blue. Then the current texture and vertex buffer are pointed at the MyRect object (we will get to that soon.). Once everything is set, DrawPrimitive is called to draw the rectangle. We draw a rectangle by drawing two triangles defined by four points. The first three points define Triangle0, the next point defines a triangle based on itself and the previous two points. (see graph).
After the primitive is cached (not really rendered yet), EndScene and Present are called, to really finish the frame rendering. EndScene tells Direct3D that there aren’t any more polygons to render, and Present is the 3D equivalent of DirectDraw’s Flip. It should be noted that this will only draw one bitmap (the one defined by MyRect). In any real application, this would be a loop, which read from a list (I use a vector) of polygons, setting the texture, setting the vertex buffer, and rendering the primitive for each of the polygons in the list.

Poly

The Poly object encapsulates a vertex buffer and a texture pointer. The Poly object initializes the vertex buffer, and sets it to arbitrary (full-screen) coordinates.

    class Poly
    {
    public:
    	Poly() : m_VertexBuffer(NULL) { Init(); }
    	~Poly() { Clear(); }
    	void Clear();
    	void Init();
    	void SetVertRect(int x, int y, int w, int h, float d); // Sets the
    rectangle used by the Verticies.

    	LPDIRECT3DVERTEXBUFFER8	m_VertexBuffer;
    	Texture					m_Texture;
    }

    void Poly::Clear()
    {
    	SAFE_RELEASE(m_VertexBuffer);
    }

The Clear function just wipes the vertex buffer, and sets it to NULL.

    void Poly::Init ()
    {
    	// Create a 4-point vertex buffer
    	if( FAILED( MyD3D.m_pd3dDevice->CreateVertexBuffer(
                4*sizeof(CUSTOMVERTEX), 0, D3DFVF_CUSTOMVERTEX, D3DPOOL_DEFAULT,
                &m_VertexBuffer) ) )
    		return ;

    	CUSTOMVERTEX* pVertices;
    	if( FAILED( m_VertexBuffer->Lock( 0, 4*sizeof(CUSTOMVERTEX),
              (BYTE**)&pVertices, 0 ) ) )
    		return ;

    	pVertices[0].position=D3DXVECTOR3( -1.0f,  1.0f, 0.5f );
    	pVertices[0].color=0xffffffff;
    	pVertices[0].tu=0.0;
    	pVertices[0].tv=0.0;
    	pVertices[1].position=D3DXVECTOR3( 1.0f,  1.0f, 0.5f );
    	pVertices[1].color=0xffffffff;
    	pVertices[1].tu=1.0;
    	pVertices[1].tv=0.0;
    	pVertices[2].position=D3DXVECTOR3( -1.0f,  -1.0f, 0.5f );
    	pVertices[2].color=0xffffffff;
    	pVertices[2].tu=0.0;
    	pVertices[2].tv=1.0;
    	pVertices[3].position=D3DXVECTOR3(  1.0f,  -1.0f, 0.5f );
    	pVertices[3].color=0xffffffff;
    	pVertices[3].tu=1.0;
    	pVertices[3].tv=1.0;

    	m_VertexBuffer->Unlock();
    }

First, the Init function creates a vertex buffer with four points. This is a fairly straightforward function (receives the size of the buffer, the type of vertex, the memory pool to grab it from, and the pointer used to return the buffer).
Next, the buffer is locked so that we have access to the actual data. The lock function returns the pointer to our data.
After that, we set each vertex to the correct values. All position values are based on the assumption that the screen goes from -1.0 to 1.0 in both the X and Y direction, with the origin at the center of the screen. The third value is, of course, the Z value, and has valid values of 0.0 to 1.0.
The color defaults to white (with full opacity) the reason for this is that we will be reading all of our color data from the texture, not from the individual corners of the polygon. Note that although we set the opacity to 0xff, this does not preclude the texture map from using its own alpha mask.
The TU and TV values are anchors into the texture map. These values are also valid from 0.0 to 1.0. Usually, you won’t ever want to mess with these.
Once all the vertices are set up, you unlock the rectangle to get your polygon ready for rendering.
Now that the polygon is all set up, suppose you want to alter the vertex locations (after all, not every polygon is going to be full-screen).

    void Poly::SetVertRect(int x, int y, int w, int h, float d)
    {
    	float left, top, right, bottom;
    	left	= ((float)x  (float)VIEWPORT_WIDTH) * 2.0f - 1.0f;
    	right	= ((float)(x + w)  (float)VIEWPORT_WIDTH) * 2.0f - 1.0f;
    	top	= ((float)y  (float)VIEWPORT_HEIGHT) * 2.0f - 1.0f;
    	bottom	= ((float)(y + h)  (float)VIEWPORT_HEIGHT) * 2.0f - 1.0f;

    	CUSTOMVERTEX* pVertices;
    	m_VertexBuffer->Lock( 0, 4*sizeof(CUSTOMVERTEX), (BYTE**)&pVertices, 0);

    	pVertices[0].position=D3DXVECTOR3( left,  bottom, d );
    	pVertices[1].position=D3DXVECTOR3( right,  bottom, d );
    	pVertices[2].position=D3DXVECTOR3( left,  top, d );
    	pVertices[3].position=D3DXVECTOR3( right,  top, d );

    	m_VertexBuffer->Unlock();
    }

This function translates a rectangle (x, y, width, height) into correct vertex locations, and alters the vertex buffer accordingly. If, for instance, you were slowly moving a sprite from the right side of the screen to the left you might call SetVertRect(--x, y, width, height, depth) once per frame. This code takes the assumed locations (based on a Viewport_width, Viewport_height coordinate system), and resizes them to the correct -1.0 to 1.0 value. After that, it locks the buffer and writes out the changes.

Texture

    class Texture
    {
    public:
    	Texture() { Clear(); }
    	Texture(const char * Filename) { Init(Filename); }
    	~Texture() { Clear(); }
    	void Init (const char * Filename);
    	void Clear();

    	LPDIRECT3DTEXTURE8	pTexture;
    	int m_Width;
    	int m_Height;
    	int m_Pitch;
    };

The Texture Object holds image-specific data, and the buffer to the actual texture.

    void Texture::Init (const char * Filename)
    {
    	TGAFile file;
    	file.read(Filename);
    	m_Width=file.m_Width;
    	m_Height=file.m_Height;
    	m_Pitch=file.m_Pitch;

    	MyD3D.m_pd3dDevice->CreateTexture( file.m_Width, file.m_Height, 0, 0,
            D3DFMT_A8R8G8B8, D3DPOOL_MANAGED, &pTexture );

    	D3DLOCKED_RECT d3dlr;
    	pTexture->LockRect( 0, &d3dlr, 0, 0 );
    	DWORD * pDst = (DWORD *)d3dlr.pBits;
    	int DPitch = d3dlr.Pitch4;
    	DWORD * pSrc = (DWORD *)file.m_PixelData;
    	int SPitch = file.m_Pitch4;

    	for (int i=0; i<file.m_Height; ++i)
    		for (int j=0; j<file.m_Width; ++j)
    			pDst[i*DPitch + j] = pSrc[i*SPitch + j];

    	pTexture->UnlockRect (0);
    }

In this sample, textures are filled with TGA file data. However, reading TGA files is not the interesting thing about this function. The interesting part starts with the CreateTexture function. This function receives as parameters a width, height, image format, and the type of memory used to allocate the texture. It returns a pointer to the newly created texture.
Once the pointer is received, we can lock the rectangle that holds that texture. Again, we follow the standard DirectX pattern of locking a resource, filling the data it points us to, and unlocking the resource.
Consider this section:

    	for (int y=0; y< Height; ++y)
    		for (int x=0; x< Width; ++x)
    			pDst[y*DPitch + x] = pSrc[y*SPitch + x];

This is the key to the whole program. While you have access to the texture pointer, you can treat the texture just like a DirectDraw Surface Pointer. You can use this pointer to draw Bresenham lines, circles, or bitmaps (as in this sample case). In this section of code, we copy from a source bitmap to a destination bitmap. However, we could just as easily fill with a specific color:

    	pDst[y*Dpitch + x] = ( alpha << 24 | red << 16 | green << 8 | blue )

One other point, I have glossed over several of the parameters in some of these functions, and it would be well worth the trouble to look them up in the DX8 SDK. For instance, in the Lock and Unlock functions, the first parameter is a 0, which indicates that you are locking the 0th stage identifier. Every surface has up to 8 surfaces, but for 2D rendering we only use the 0th. Since it was not needed for 2D rendering, I didn’t mention this, however, you may still want to read up on these functions when you are implementing them.

Conclusion

n this sample we have seen how to implement Direct3D to perform fast 2D functions. Also, we have seen how we can easily gain important 2D functions (stretching, alpha blending, etc.) without sacrificing our 2D coding knowledge. There have been some slight changes in the source that is bundled with this text, but the changes are largely cosmetic. I took out the TGA reader to make the code more readable. Download the source code here: article_dx8adv2d.zip (43k)
Brand Gamblin has worked as a game programmer for the last five years at Microprose Hunt Valley Studio and Kinesoft Austin. He's now looking for work, and maintaining his own website at http://www.niftycode.com.

Copyright 1999-2008 (C) FLIPCODE.COM and/or the original content author(s). All rights reserved.
Please read our Terms, Conditions, and Privacy information.