在DirectX 中进行2D渲染
http://flcstudio.blog.163.com/blog/static/756035392008115111123672/
最近,我看到很多关于DirectX8在最新的API中摒弃DirectDraw的问题。很多人回到了以前的DX7.1中。我可以理解那些在DX7.1中有很多开发经验的人为什么这样做,但是有很多问题却是来自于那些刚学DX,还没有学过以前的API的初学者。人们争辩说很多人没有3D硬件,因此D3D对于DirectDraw是个错误的选择。我不相信那是真的,在D3D中进行2D渲染只需要做一点顶点操作,而其他的事情都可以被精简来提高填充率。简言之,在D3D中使用2D硬件进行2D渲染,可以做到和DirectDraw一样好的性能,有很好的填充率。而优点是,程序员可以学习最新的API,并且在更新的硬件中获得更好的性能。这篇文章将给出一个在DX8中进行2D渲染的框架,以便于从DirectDraw到Direct3D的转变。在每一节里,你会看到一些你不喜欢的东西(“我是一个2D程序员,我不用关心顶点!”)。但是,请放心,只要你将这个简单的框架实现一次,你就再也不会考虑那些了。
假设你已经有DX8 SDK,那儿有一组指南讲述了如何创建一个D3D设备,如何放置渲染循环,因此我不想再在这上面花费时间。按照这篇文章的意图,我将谈论位于[DX8SDK]samplesMultimediaDirect3DTutorialsTut01_CreateDevice目录里的指南,你可能将它放到了任何地方。在那个例子中,我将加入以下函数:
- 其他所有事情都设置完之后,这个函数被app调用,你已经创建了你的设备并且所有东西都已经初始化了。如果你跟着往下看指南里的代码,你会看到WinMain是这样的:
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()
- 这个函数当你渲染你的场景时会被调用,指南里的Render函数现在看起来是这样的:
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 );
}
好了,这就是我们的程序外壳,现在来准备好的内容填充它吧...
为在D3D中进行2D绘制进行设置
注意:从这儿开始我们就要谈论一些和D3D相关的令人讨厌的数学知识了。不要被吓倒了---如果你愿意,你可以选择乎略大多数细节。大多数Direct3D绘制都受三个矩阵控制:投影矩阵,世界矩阵,观察矩阵。我们将谈论的第一个矩阵是投影矩阵。你可以认为投影矩阵定义了你的摄像机的镜头属性。在3D应用里,它定义了象透视方法,等等的东西。但是我们用不着透视---我们正在谈论2D!!所以我们只谈论正交投影。简短得说,就是让我们进行2D绘制而不用考虑那些附加在3D绘制中的属性。为了创建一个正交投影矩阵,我们需要调用D3DXMatrixOrthoLH函数,它将为我们创建一个矩阵。其他的矩阵(观察矩阵和世界矩阵)定义了摄像机的位置和世界(或一个在世界里的对象)的位置。为了我们的2D绘制,我们不需要移动摄像机,也不用想移动世界,所以我们将使用一个单位矩阵,将摄像机和世界放置在缺省的位置。我们可以用D3DXMatrixIdentity函数来创建单位矩阵。我们需要加入下面的头文件,以使用D3DX函数。
PostInitialize函数现在是这样子的:
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);
}
我们现在正在为2D绘制进行设置,我们需要绘制些东西。这样设置了之后,我们的绘制区域就是从-WindowWidth/2 到 WindowWidth/2和从 -WindowHeight/2 到 WindowHeight/2。要注意的一件事是,在代码里,宽度和高度都是用象素为单位指定的。这允许我们用象素来考虑所有的事情,但我们也能设置宽度和高度为1.0,然后允许我们用屏幕空间的百分比来指定大小,这样就很容易的支持了多分辨率的情况。改变矩阵就能够支持各种各样的巧妙的事情,但为了简单起见,我们现在将谈论象素。
设置一个2D“面板”
当我进行2D绘制时,我有一个叫CDX8Panel的类,它装封了所有我绘制2D矩形所需要的东西。简单起见,它消除了C++说明,我已经将代码拿了出来了。无论如何,当我们建造我们的一个绘制面板的代码时,你将可能看到一个类的价值,或者如果你不使用C++时一个更高层的API的价值。同样,依靠ID3DXSprite接口,我们也可得以更加悠闲。我将在这儿解释最基本的东西,以展显事情工作的方法,但是如果Sprite接口适合你的需要,你也可以使用它。
我的面板定义是一个简单的2D纹理矩形,我们将会把它绘制到屏幕上。绘制一个面板非常类似于2D的blit操作。有经验的2D程序员可能会想一个blit操作会有大量的工作,但是这些工作完成了很多允许的特效。首先,我们不得不考虑我们的矩形的几何结构。这样就包括了关于顶点的思想。如果你有3D硬件,硬件将非常快地处理这些顶点。如果你只有2D硬件,我们所谈论的如此少的顶点也将很快的被CPU处理完成。首先,让我们定义我们的顶点格式。将以下的代码放置到靠近#i nclude:的地方
struct PANELVERTEX
{
FLOAT x, y, z;
DWORD color;
FLOAT u, v;
};
#define D3DFVF_PANELVERTEX (D3DFVF_XYZ | D3DFVF_DIFFUSE | D3DFVF_TEX1)
这个结构和灵活的顶点格式(FVF)定义了我们所谈论的包含位置,颜色和一组纹理坐标的顶点。
现在我们需要一个顶点缓冲。加入下面代码行到全局列表中。又是为了简单,我让它成为全局的---这可不是一个好的编码习惯的示例。
LPDIRECT3DVERTEXBUFFER8 g_pVertices = NULL;
现在,加入下面的代码行到PostInitialize函数(下面再说明):
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();
这实际上比看起来还要简单。首先,我构造了面板的大小,我们有一些工作会用到它们。接下来,我请求设备创建一个包含用我的格式定义的四个顶点的足够大内存的顶点缓冲。然后我锁定缓冲以使我能够设置顶点的值。值得注意的一点是,锁定缓冲是一很昂贵的,所以,我将只这样做一次。我们可以操作这些顶点而不用锁定,不过我们将在以后讨论。在这个例子中,我设置了四个对(0,0)居中的点。记住这一点,以后会有衍生而出的讨论。另外,我设置了纹理坐标。SDK已经很好的说明了,所以我没有进行讨论。简短的说就是我们进行了设置,来绘制整个纹理。这样,现在我们已经设置好了矩形。下一步就是绘出它?
绘制面板
绘制矩形是很简单的。增加下面的代码行到你的Render2D函数:
g_pd3dDevice->SetVertexShader(D3DFVF_PANELVERTEX);
g_pd3dDevice->SetStreamSource(0, g_pVertices, sizeof(PANELVERTEX));
g_pd3dDevice->DrawPrimitive(D3DPT_TRIANGLEFAN, 0, 2);
这些行告诉设备顶点如何被格式化,使用哪些顶点,以及如何使用它们。我选择将这些当作三角形扇进行绘制,因为这样比绘制两个三角形更紧凑。注意,因为我们没有和别的顶点格式或顶点缓冲的处理,我们可以移动第一行到我们的PostInitialize函数里去。我将它们放到这儿是为了强调你不得不告诉设备它将要处理的是什么。如果你不这样做,它将假设顶点是不同格式的,并且导致崩溃。这时,你就可以编译运行代码了,如果一切正常,你会看到在蓝色的背景里有一个黑色的矩形。这还不是很正确的,因为我们设置顶点的颜色是白色的。这个问题是设备允许了光照,这是我们不需要的。通过在PostInitialize函数里加入这行来关掉光照:
g_pd3dDevice->SetRenderState(D3DRS_LIGHTING, FALSE);
现在,重新编译,设备将会使用顶点的颜色了。如果你喜欢,你可以改变顶点的颜色看看效果。到目前为止,一切顺利,但是一个显示一个白色矩形的游戏看上去是很令人厌烦的,我们还没有触及到blit一个位图。所以,我们不得不加入纹理。
为面板粘贴纹理
纹理是一个能够从文件装入或者通过数据生成的基本的位图。为简单起见,我们只使用文件。将下面的变量加入到你的全局变量中:
LPDIRECT3DTXTURE8 g_pTexture = NULL;
这便是我们将要使用的纹理对象。加入这行代码到PostInitialize函数以从文件装入纹理。
D3DXCreateTextureFromFileEx(g_pd3dDevice, [Some Image File], 0, 0, 0, 0,
D3DFMT_A8R8G8B8, D3DPOOL_MANAGED, D3DX_DEFAULT,
D3DX_DEFAULT , 0, NULL, NULL, &g_pTexture);
你可以用你选择的文件名替换[Some Image File]。D3DX函数可以装入很多标准格式的文件。我们所使用的象素格式是有alpha通道的,所以我们装入带alpha通道的格式文件,象.dss文件。另外,我也乎略了ColorKey参数,但是你也可以指定一个ColorKey以进行透明。我会回头来讨论一点关于透明的知识。现在,我们有了一个纹理并且已经装入了一个图片。然后我们要告诉设备使用它。加入下面的代码行到Render2D函数的开头:
g_pd3dDevice->SetTexture(0, g_pTexture);
这告诉了设备用纹理来渲染三角形。这儿要特别记住的事情是考虑到简单我没有加入错误检查。你应该进行正确的错误检查,以确定在纹理被使用之前已经被实际的装入了。一个可能的错误是,在很多硬件中,纹理的大小必须是2的幂,如:64X64,128X512,等等。对于最新的nVidia硬件,这个约束不再是正确的了,但是安全起见,请使用2的幂。这个限制让很多人感到烦心,所以一会儿我会告诉你如何绕过这个限制。现在,编译运行,你可以看到你的图片已经映射到了三角形上了。
纹理坐标
注意纹理会被拉伸和缩短以适合矩形。你可以通过调整纹理的坐标来调整这些。例如,如果你把u=1.0那行改为u=0.5,那么只有一半的纹理被使用,而剩下的另一半不会被压缩。所以,如果你有一个640X480的图片,你想把它放到一个640X480的窗口中去,你应该将640X480大小的图片放到一个1024X512大小的纹理中去然后指定纹理坐标为0.625,0.9375。你可以使用纹理中剩余的部分来放置那些会被映射到其他的面板中去的子图片(通过相应的纹理坐标)。通常,你会想优化纹理被使用的方式,因为它们吃光了图片内存并且在总线中移动。这看上去很象blit中的大量的工作,但是很多都会被新式的为3D进行优化的显卡处理掉了。此外,多多考虑如何在系统中移动大块的内存决不是一个坏的想法。但是我还是开始我的演说吧。
让我们来看看我们走了多远。首先,我们写了很多代码来blit一个简单的位图。但是,希望你能够看到一些好处和机会。例如,纹理坐标自动缩放图片以适应我们的几何定义。这为我们做了很多工作,但是考虑到后面。如果我们设置使用一个基于百分比映射的垂直矩阵,并且,我们指定一个占据屏幕底部四分之一位置的面板(让我们说它是UI吧),而且我们也用正确的纹理坐标来指定它的纹理,这样,我们的UI在任何选定的窗口/屏幕大小下都会被自动的正确绘制出来。(Not exactly cold fusion),但这只是很多例子中的一个。现在我们已经让纹理可以工作的很好了,我们回过头来谈论一下透明。
透明
象我以前所说的,加入透明的一个简单的方法就是在调用D3DXCreateTextureFromFileEX函数里指定一个ColorKey值。另一个办法是使用一个实际带alpha通道的图片。无论使用哪种方法为纹理指定透明(使用alpha通道,或者ColorKey),然后运行,你都会看不到有什么区别。这是因为alpha混合还没有被允许。在PostInitialize中加入这些行以允许alpha混合:
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);
第一行允许了混合。下两行指定混合如何工作。这会有很多的可能性,不过这是最基本的类型。最后一行进行一些设置以致当改变顶点颜色的alpha成分时会缩放纹理值来减弱整个面板。关于可使用设置的更深层讨论,请参见SDK。一旦这些行被加入进来,你会看到正确的透明。试着改变顶点的颜色来看看它将如何影响面板。
移动面板
现在我们的面板已经有了很多我们需要的视觉属性,但它还只是粘在我们的视口中央。在游戏中,你可以想让一些东西移动起来。一个显而易见的方法是重新锁定顶点,然后改变它们的位置。千万不要这样做!锁定是很昂贵的操作,它包括数据的移动,并且这是不必要的。一个更好的方法是指定世界变换矩阵来移动这些点。对于很多人来说,矩阵看上去是有一点吓人的,但是在D3DX中有一大群函数让矩阵使用起来非常简单。例如,为了移动面板,在Render2D函数的开头加入下面的代码:
D3DXMATRIX Position;
D3DXMatrixTranslation(&Position, 50.0f, 0.0f, 0.0f);
g_pd3dDevice->SetTransform(D3DTS_WORLD, &Position);
这里创建了一个可以在X方向移动面板50个象素的矩阵,然后告诉设备应用这个移动。这可以被装封到一个象MoveTo(X,Y)的函数里去,不过我没有实际给出这样的代码。前面,我说过要记住顶点是相对于原点来定义的。因为我们是这样做的,所以,平移(移动)移动了面板的中心位置。如果你认为移动左上角或是其他的角会更加适合,请改变顶点定义的方式。你也可以通过传递正确的参数到MoveTo函数来创建不同的坐标系统。例如,我们的视口当前是从-100到100。如果我想将视口认为是从0到200那样来使用MoveTo函数,我可以简单的在我调用D3DXMatrixTranslation时从X坐标中减去100来进行更正。有很多方法可以很快的改变以使你能看到你所想要的效果,但是,作为实验这将提供一个好的基础。
其他的矩阵操作
有很多其他的矩阵操作可以影响面板。最有趣的可能是缩放和旋转了。有一些D3DX函数可以很好的创建这些矩阵。我将把这些实验留给你来做,不过这儿有一些提示。关于Z轴的旋转将会在屏幕上旋转。而关于X和Y轴的旋转将看上去象是Y轴和X轴在收缩。另外,应用多个操作的方法是通过乘法,然后将结果矩阵送给设备:
D3DXMATRIX M = M1 * M2 * M3 * M4;
g_pd3dDevice->SetTransform(D3DTS_WORLD, &M);
不过,记住矩阵乘法的结果是依赖于操作数的顺序的。例如,Rotation*Position将移动面板然后旋转它。Position*Rotation将导致一个沿轨道而行的效果。如果你排列了几个矩阵在一起,但是得到了并不期待的结果,请仔细的看看排列的顺序。
当你变得更加轻松时,你可以想去试试象纹理矩阵这样的东西,它将允许你移动纹理坐标。你也可以移动观察矩阵来影响你的坐标系统。记得一点:锁定是非常昂贵的,在你锁定你的顶点缓冲之前,总是先看看象矩阵这样的东西。
装封
看看这儿列出的所有的代码,为了进行blit我们走了很长的一段路,但是好事是很多都可以被装封到一些小的函数或是类中去,这样我们就可以一劳永逸了。请注意,这儿是使用一种非常梗概的而且没有优化的方法来表示的。有很多方法可以将这些包装起来以获得最大的收益。这可能是在当前的和以后的硬件上创建2D应用程序的最佳方法,而且你也可以获得在硬件上可以很简单的实现那些效果的好处。这种方法也可以帮助你在3D中混合进2D元素,因为在矩阵面前,它们是一样的。这些代码也可以简单的适合在OpenGL中进行2D工作,所以你甚至可以写一个抽象装封来支持两种API。我的希望是这可以让人们使用DX8来做2D工作。可能在以后的文章中我会讨论更多的技巧和效果。