【教程】使用DX9做一个2D游戏(1)

本文最先发表在贴吧,现在整理到此处,之后所有更新将在这里进行。
by Chu @ XDU 2012/11/25
版权所有,禁止用于商业用途。
转载请注明出处。

用DX9做一个2D游戏显然不是一件容易的事情。本文主要面向所有初学者所写,因此会广而不精地讲解2D游戏编程中遇到的各方面东西,主要以API为讲解对象。至于算法,因为不同游戏涉及的不一样,所以不一定会在下文讨论到。

一、图形呈现过程

【注:这部分内容其实应该和D3D一起讲,但是既然写在了前面,就先讲掉了,等用到的时候再回来看。】

     对于游戏而言,最重要的就是视觉效果,于是怎么呈现图像,图像怎么呈现的,这是游戏编程中最核心的地方。
     计算机中表示三维图形的方法只能是使用三角形去表示一个面,对于长方体,就用两个三角形表示,对于球,就用一大堆三角形去近似表示,三角形数量越多越精细。而所谓的曲面细分也是如此,本质上就是产生一大堆三角形。
     于是为了在空间显示三角形,就要指定三个顶点的坐标,为了让三角形填充上颜色,可以为三角形的顶点加上颜色,然后计算机会在光栅化过程中对三角形中的像素进 行插值,算出颜色再填上去。于是一个三角形就诞生了。然后,为了更加丰富表示色彩,又加上了贴图。为了方便定位,又给每个顶点加上了贴图坐标(u,v), 然后光栅化的时候就会在贴图上算出坐标进行插值。
     在拥有三角形信息之后,需要把图形的坐标转换到屏幕坐标空间



    于是矩阵的作用就在这里体现了,用一个矩阵去表示一种变换过程,再通过Shader或者固定的渲染管线去对每一个点进行一下矩阵变换,使之变换到屏幕坐标空间。
计算公式类似于:
              V变换后 = V原始 * Matrix变换
            【注:对于行式和列式,乘法顺序不同】
     无论你家显示器是什么分辨率的,屏幕坐标空间的XY坐标范围都是(-1.0~1.0),而对于Z坐标,其取值通常是0.0~1.0(对于D3DVIEWPORT9里面的ZMin和ZMax),然后越往屏幕里面Z越大,OPENGL则与DX不同。
     对于DX9和GL上的固定渲染管线而言,这一阶段,程序员要做的就是提供变换矩阵和要变换的这些点的信息(包括点数据,每个点的数据组成方式(FVF顶点格式)),然后管线会自动完成计算。而对于Shader可编程管线而言,这部分同样要提交上面的数据,而且这部分的变换交给程序员自己编写的Shader代 码负责(VertexShader)。GL同理。
      接下来,显卡则会开始光栅化过程,而这部分,显卡会去读取提交给他的纹理数据以及上一步变换完成 的顶点数据,然后在绘图同时完成ZBuffer深度测试、模板测试、Alpha测试以检查三角形上的像素是否会被绘制。如果会被绘制,则会画到缓冲区上, 最后该阶段结束,缓冲区被刷到屏幕上呈现(Present)出来。
对于可编程渲染管线,该过程全程(各种测试依旧由显卡完成)由PixelShader负责,负责决定什么颜色会被画到屏幕上。

      整个绘图过程大致就是这样。

1.1 2D与3D
     对于所谓的2D游戏,也就是只要x,y坐标即可。通常,2D游戏会以窗口左上角为原点,右侧下侧为正方向。而现在我们要用3D空间去描述一个2D点。为了简单起见,我们要求2D坐标系和数学上的坐标系一致,以屏幕中间为坐标原点,上方右方为正方向。这样,我们的坐标系就和上图的屏幕坐标空间一致了。

     但是注意到屏幕坐标系中X与Y的坐标范围都是(-1~1之间)。往往写游戏的时候会以像素作为单位,于是我们需要将以像素为单位的点通过一个缩放变换转换到屏幕坐标系上

     而这个过程我们通过引入矩阵来实现。D3DX中存在一系列矩阵可以完成这种功能,在框架中有两个矩阵可以完成上述功能

static V3DMatrix4 OrthoLH(
    const vInt w,             // 横向可视范围
    const vInt h,             // 纵向可视范围
    const vFloat nearPlane,   // 最近距离
    const vFloat farPlane)    // 最远距离
{
    return V3DMatrix4( 2.0f/w, 0.f, 0.f, 0.f,
        0.f, 2.0f/h, 0.f, 0.f,
        0.f, 0.f, 1.f /(farPlane-nearPlane), 0.f,
        0.f, 0.f, nearPlane/(nearPlane - farPlane), 1.f);
};

static V3DMatrix4 OrthoRH(
    const vInt w,             // 横向可视范围
    const vInt h,             // 纵向可视范围
    const vFloat nearPlane,   // 最近距离
    const vFloat farPlane)    // 最远距离
{
    return V3DMatrix4( 2.0f/w, 0.f, 0.f, 0.f,
        0.f, 2.0f/h, 0.f, 0.f,
        0.f, 0.f, 1.f /(nearPlane - farPlane), 0.f,
        0.f, 0.f, nearPlane/(nearPlane - farPlane), 1.f);
};

      【注:其实这是个正投影矩阵】
     这个函数有两个版本,RH LH,分别表示右手坐标系和左手坐标系,左右手定义如下

     为了方便起见,我们在这里使用左手系。
     通过创建一个变换矩阵,那么对于点P,可以通过矩阵乘法完成这个点到屏幕空间的变换
     伪代码(好吧,其实这完全可以运行):

Vector3 P(100.f,100.f, 100.f);
P *= Matrix4::OrthoLH(800, 600, 0.1f, 1000.f);

     这样P就经过了这样一个变换,最后其坐标就被转换到了屏幕坐标系中。
     可能你还会希望以窗口坐标系为基准,通过自己推导矩阵这也是可以做到的,D3DX中也提供了这样一个函数,可惜我忘了是哪个= =。
     细心一点,你可能会发现上面的伪代码中,点P(100,100)多了一个Z坐标。
     因为我们用的是3DAPI,所以事实上我们所画的都只能是3D图形。那么3D的坐标系中,一个点就应该是(x,y,z)这样定义了。那么你可能会觉得这样z值没用了,我是不是可以随意设或者设为0?
     事实是依据矩阵中nearPlane/farPlane的值,如果你的Z坐标超过了(nearPlane, farPlane),这个点会被剔除掉。原因如前篇,屏幕空间有一个Z值,它的范围是(0~1)。而矩阵在变换点的时候会把这个点的Z值换算到(0,1)之间,一旦这个值超过这个范围,那么这个点会被抛弃。所以,Z的取值只能在(nearPlane, farPlane)里面。
     可能你会觉得这样很多此一举,或者会对屏幕上多了一个不存在的Z轴感到疑惑。
     事实上这里的Z值是用来进行ZBuffer剔除的。在进行光栅化处理的时候,会进行ZBuffer测试(也被称为深度测试)。这种测试其实是为3D对象而诞生的。过去的时候,硬件不好,3D物体的排序会通过CPU进行,而现在,这种排序可以被Z测试取代。也就是说如果一个点要被更新到屏幕上(x,y)位置,那么它会先去看看(x,y)位置的Z值,如果(一般而言)比原始值小,那么就覆盖掉原来的像素。虽然本质上这是一种暴力做法,没有所谓的排序过程。这样就很好的实现了物体前后的顺序。
     不过对于2D物体而言,可能完全没有这个必要。毕竟排个序比3D简单多了。std::sort完爆。
     所以你也可以完全无视掉Z值(但是请保证Z落在(nearPlane, farPlane)之间),然后关闭Z缓冲,这样一来,后面的点会盖在之前的点上面。绘制顺序也由自己控制。
【注:事实上,2D物体手动排序有一定意义。这个意义还是源自于ZTest的一种缺陷。即对于半透明的物体,如果半透明的物体在前面,而且先被绘制了,那么通过ZBuffer测试,后面的物体在之后绘制是不会修改掉像素的,这样一来透明效果就不正确了。所以3D情况下,透明物体往往会被拿出来最后排序再绘制。而在2D情况下,各种特效都是由半透明混合而成,如果借助于ZTest而打乱绘制顺序,很可能会出现糟糕的效果。见下图。所以2D绘图,ZBuffer显得有点鸡肋】

1.2 纹理与纹理过滤

     纹理,通俗的说就是贴图(比如说使命召唤都是贴图堆起来的,那说的即是纹理),再通俗一点,就是一张张图片。但是这么说又太片面了,事实上,纹理可以分成一维纹理,二维纹理,三维纹理。
     所谓的一维纹理,直白一点,是一个一维数组,其成员是一个个像素。二维纹理就是二维数组,三维纹理就是三维数组。如果站在数组的角度,大概可以这么写:

// 设长为x,宽为y,高为z。行优先储存
Pixel Texture1D[x];
Pixel Texture2D[x][y];
Pixel Texture3D[x][y][z];

 

     但是纹理内部存储的时候为了保证访问效率,对于特定情况下会让x(3d可能还有y)所占的字节数保证为4的倍数。就是说可能事实上表示为

Pixel Texture2D[v][y];
// 其中v>=x

     DX9的纹理使用IDirect3DTexture9接口,其本质上由N个Surface组成,在这里Surface用来存放Mipmap(具体见下)。而对于DX10/11而言,这个过程就更为复杂。其纹理资源划分的很细。
【注:由于DX10/11不在本文讨论范围,详情见 http://www.cnblogs.com/9chu/archive/2012/11/19/2776111.html
     那么怎么使用一个纹理(下文的纹理若非特殊说明,均指代2D纹理)呢?
     纹理本身是不能单独渲染的,必须要绑定到三角形上,走一遍管线最后才能被渲染到屏幕上。
     那么如何来绑定一张纹理呢?
     首先,对于一个三角形而言,它有三个顶点,为了绑定上纹理,我们需要指定纹理坐标,如此,在光栅化过程中,管线会通过插值计算出屏幕上的点对应纹理上的哪个像素。如图。

      上图表示了纹理坐标系。
      DX中的纹理坐标系很类似于窗口坐标系,其中x轴取名叫u,y轴叫做v,所谓的uv坐标就是指这里的点坐标(注意OpenGL下坐标系v朝上,与DX不同)。对于任意一个纹理,无论长宽,在坐标系中,纹理上的坐标(x,y)会被映射到[0,1]区间。
      而对于超过这个坐标的点,可以选用其他的映射方式,比如对应的像素值为0(黑色),或者取模,或者镜面,等等。如图

     知道了纹理坐标系,接下来要干的,就是给每个顶点设置上坐标。

     如此,纹理就贴到了三角形上面。

     于是乎,一个新的问题又出现了。
     如果说纹理只是按照原始尺寸,不经过旋转,直接贴到屏幕上。那么这个过程很简单,我们拷贝下像素就好了。
     但是纹理可能会经过放大,或者经过缩小甚至变形。这样导致一个插值,我们知道不同的插值算法导致的结果会不同,而且效果越好,代价也越大。
     而这个功能的控制就交给了纹理过滤来做。
     在玩游戏的时候,可以看到选项里面有二线性、三线性、各向异性这些选项,所指的也就是这种插值算法类型,其中二线性最廉价,各向异性代价最高(其中各向异性还可以设置采样点数量)
     在DX9\OpenGL中,纹理过滤通过一些状态函数去设置。而到了DX10/11,纹理过滤参数直接与采样器(Sampler)挂钩。所谓的采样器就是依据这种算法(包括纹理过滤和上文提到的纹理寻址过程),给定纹理坐标输出对应的像素点。
     DX9中可以这样设置:

m_pDev->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
m_pDev->SetSamplerState(0, D3DSAMP_MIPFILTER, D3DTEXF_LINEAR);
m_pDev->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR);

 

     我们可以看到有三个采样源,分别是MIN、MAG、MIP。
     MinFilter指代贴图被缩小时的处理方式。这里会取最接近的4个点平均。
     MagFilter则指代被放大时的处理方式。这里会选取最接近的4个点内插。
     MipFilter则指Mipmap采样。将会选择Mipmap里面最接近变换后尺寸的两个贴图进行插值。
     因为看到了三个LINEAR(线性)方法,故可以称为三线性过滤。
     而对于各向异性,则是:

m_pDev->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
m_pDev->SetSamplerState(0, D3DSAMP_MIPFILTER, D3DTEXF_LINEAR);
m_pDev->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_ANISOTROPIC);
m_pDev->SetSamplerState(0, D3DSAMP_MAXANISOTROPY, 8);

 

     注意到MinFliter被换成了ANISOTROPIC,下面还设置了8个采样点。
     也可以全部设置成D3DTEXF_ANISOTROPIC,不过没这种必要吧。。。

     从图中可以发现对于2D而言,三线性和各向异性上没有太大差别。反而关闭或者开启Mipmap之后,贴图出现明显的柔化现象。

     最后我们再来说说上面提了很久的Mipmap。
     何为Mipmap?
     事实上,Mipmap就是原图的缩略图,而且是好几张。
     比如原图是1024*1024的,那么会产生出
        512*512 256*256 64*64 32*32 16*16 8*8 4*4 2*2 1*1
     这些不同分辨率的缩略图。即之后的一张长宽是前面的二分之一。
     将这些图按照大到小依次排列,就会得到一列图片,形如倒金字塔(故mypcluna将mipmap称为纹理塔)。我们按照层次(Mipmap Level)去称呼每一张图片,那么第一张(即第0层)就是原始贴图。


     产生Mipmap的过程通常是在加载纹理的过程中进行。如果使用D3DXLoadTextureFrom*系的函数加载纹理,则会在调用的时候计算出贴图的Mipmap。对于DDS贴图,Mipmap则是已经计算好的,直接读入就行。注意到一个IDirect3DTexture9是由很多Surface组成的,其实,这么多的Surface保存的就是Mipmap。
     这样一来,一张贴图所占的空间就变大了。
     增加的大小 = (1/2)*(1/2) + (1/4)*(1/4) …… ≈ 0.3
     就是原始贴图的1.3倍大小
     可是为什么要这种东西呢?
     考虑一个事实,若我们有一个贴图,大小为1024*1024,然后这个贴图被缩小到只有1*1大小。光栅化的时候,GPU会对整个贴图取平均值来计算出最后1*1大小的像素的颜色值。
     这个代价显然太高了,所以为了节约计算时间,我们采取空间换时间的做法来提高运算速度。
     这样,绘制某个大小的时候,会先到Mipmap里面找到最接近的贴图,再进行插值运算。
     这就是Mipmap的作用。

1.3 抗锯齿和其他

     抗锯齿用于解决另一个问题。

     【注:明显看到启动抗锯齿以后FPS掉的很厉害】

     说白了,抗锯齿可以处理边缘,让边缘柔和过度。也就是消除右图中出现的锯齿。
     对于正正方方的图形可能作用不大,但是对于这种被旋转的图形,效果很明显。
     DX9在初始化的时候设置

m_D3Dpp.MultiSampleQuality = 0;
m_D3Dpp.MultiSampleType = D3DMULTISAMPLE_8_SAMPLES;

 

     即可开启8x抗锯齿。
     DX10/11在初始化SwapChain时进行,之后创建View的时候也要使用特定的版本。OpenGL则更麻烦,在这里不阐述(因为我也不会XD)。
     接下来说下文字渲染。
     作为底层的图形API,无论是DX还是GL甚至XNA(除了D2D),都不提供文字渲染的功能。因为他们只能渲染三角形。
     所以在文字方面有两种解决方案。
     一种是准备一张比较大的贴图,里面放上ABCDEFG……各种字符和符号。然后要渲染的时候定位到纹理上,再渲染。
     这种方式非常适合于拉丁字符,因为就A~Z这么多字符加上少数几个符号和数字,放在一张纹理上不是什么大问题。而且这样渲染效率很高,不用实时再去画字符。但是局限性也很明显,对于中文就完全无能为力了,另外,你不能在运行的时候改变字体大小。
     于是第二种方法,使用一套文字渲染的API,Windows上可能是GDI,非Windows上可能是freetype这样的库。然后准备一个纹理,要文字的时候先用API渲染,再复制到纹理上,最后画出来。显然,这个过程很耗时,但是可以适应任意文字并且可以实时修改字体。
     于是针对于第二种方案出来一种优化方案,即准备一块大的纹理作缓冲,在渲染文字的时候先看看有没有已经渲染好的字符,有那么用第一种方式,没有,那么看看缓冲的纹理上还有没有空,有,就复制上去,再渲染,没有,就去掉最少使用的字符,再复制上去,最后渲染。
     这种方式显然比较折中,结合了一二的各种优势。
     D3DX扩展库中的FONT就是使用这种方法进行的。XNA中完成文字的渲染也有使用类似方法的。所以不失为一种好方法。

转载于:https://www.cnblogs.com/9chu/archive/2012/12/01/2797027.html

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值