DirectX学习笔记(十四):三维地形系统的实现

本系列文章由zhmxy555(毛星云)编写,转载请注明出处。  
文章链接: http://blog.csdn.net/zhmxy555/article/details/8685546
作者:毛星云(浅墨)    邮箱: [email protected] 

 

 

上个星期浅墨写的介绍三维摄像机的文章和示例程序放出以后,大家似乎都表现出了很高涨的热情,不少朋友评论或者给浅墨发邮件问什么时候讲地形和天空顶。本来浅墨是准备这个星期就开始讲可编程渲染流水线的,看大家这么强烈的要求,浅墨决定干脆把准备在后面讲的地形天空一气呵成,跟在摄像机后面一起讲了得了。所以,这篇文章就诞生了。

先上一张配套示例程序的截图:

 

修改顶点间距和缩放比例可以得到更广阔,更陡峭的山峰:


想创造出极具真实感的三维游戏世界,三维地形的模拟是必不可少,至关重要的。

三维地形模拟其实是一个很广阔的课题,它其实不仅仅局限于我们的游戏开发领域,在三维仿真,虚拟现实等领域都涉及。说起三维地形模拟,似乎有那么一丝神秘,其实,只要了解其实现原理了这所谓的地形系统模拟也就是纸老虎一只。这篇文章里我们就来揭开三维地形模拟的面纱,看看到底怎样利用一个C++类的书写,实现我们专属的三维地形系统,然后就只需几句代码,两幅图片,一个“活生生”的三维地形就跃然纸上了。

  

 

一、三维地形绘制思路分析


关于地形绘制的大体思路,其实非常简单,让我们先来看三幅图。

 





 

我们可以发现,以上的三幅图就概括了三维地形模拟的大体走向与思路。

首先是第一幅图,我们在图中可以看到,图中描绘的就是在同一平面上的三角形网格组成的一个大的矩形区域。在这里我们把他看做是一张大的均匀的同一平面上的“渔网”,显然它是一个二维的平面。图中的每一个顶点都可以用一个二维的坐标(x,y)来唯一表示。

然后第二幅图,我们就像“揠苗助长”一样,拉着第一幅图中的“渔网”的某些顶点往上提(或者往下压)。这里往上提一点,那里提一点,这样,我们就为每一个顶点都赋予了一个高度(就算有的顶点没有移动,它的高度就为0),第一幅图中的渔网就变形了,成了三维图形了。每个顶点就都有了一个高度值。用z坐标来表示这个高度值的话,那么现在三维空间中这个变形的“渔网”中的每个顶点都可以用(x,y,z)来唯一表示。

最后第三幅图,在第二幅图中的三维“渔网”的表面我们“镀上”纹理不尽相同的“薄膜”,也就是进行了一个纹理包装的过程。这样奇迹就发生了,逼真的雪原山川,奇峰怪石展现在了我们眼前。

所以,绘制三维地形的玄机,就被这三幅图联手一语道破了。

其中,第二幅图中的那个“揠苗助长”的过程可谓三维地形绘制的一招“妙棋”。

这招“妙棋”我们常常是借助高度图来完成。下面我们就来讲一讲什么是高度图。

 


二、关于高度图


高度图在三维地形模拟中扮演着非常重要的角色。下面让我们来一起探讨一下高度图的方方面面。

1.高度图的概念

高度图说白了其实就是一组连续的数组,这个数组中的元素与地形网格中的顶点一一对应,且每一个元素都指定了地形网格的某个顶点的高度值。当然,高度图至少还有一种实现方案,就是用数值中的每一个元素来指定每个三角形栅格的高度值,而不是顶点的高度值。

高度图有多种可能的图形表示,其中最常用的一种是灰度图(grayscale map)。地形中某一点的海拔越高的话,相应地该点对应的灰度图中的亮度就越大。下面就是一幅灰度图:



我们通常只为每一个元素分配一个字节的存储空间,这样高度也就只能在0~255之间取值。

因此,地形中最低点将用0表示,而最高点使用255表示(当然,这样做可能会 出现一些问题,比如地形中大部分区域的高度差别都不大,但是有少数地方高度差特别大时,不过大多数情况下这个系统都能运行的很好)

这个范围大体上来反应地形中的高度变化完全没问题,但是在实际运用中,为了匹配3D世界的尺寸,可能需要对高度值进行比例变换,然而一进行比例变换,往往就可能超出上面的0~255这个区间。所以我们把高度数据加载到应用程序中时,我们重新分配一个整型或者浮点型的数组来存储这些高度值,这样我们就不必拘泥于0~255这个范围,这样就可以随心所欲地构建出我们心仪的三维世界了。

对于灰度图中的每个像素来说,同样使用0~~255之间的值来表示一个灰度。这样,我们就能把不同的灰度映射为高度,并且用像素索引表示不同网格。

要从高度图创建一个地形,我们需要创建一个与高度图相同大小的顶点网格,并使用高度图上每个像素的高度值作为顶点的高度。例如,我们可以使用一张6×6像素分辨率的高度图生成一个6×6大小的顶点网格。

网格上的顶点不仅包含位置,还包含诸如法线和纹理坐标的信息。下图就是一个在XZ平面中的6×6大小的顶点网格,其中每个顶点的高度对应在Y坐标上。

 

另外我们在设计三维地形模拟系统的时候,会指定一下相邻顶点的距离(水平距离和垂直距离一样)。这个距离在上图中用“Block Scale”表示。这个距离如果取小一点的话,会使顶点间的高度过渡平滑,但是会减少网格也就是三维地形的整体大小;反之,相邻间顶点的距离取大一点的话,顶点间的过渡会变得陡峭,同时网格也就是三维地形的整体尺寸会相对来说变大。在上图中,如果两个顶点间的距离我们设为1米的话,那么所生产地形的大小就是25平方米,很好理解吧。

最常用的灰度图格式是后缀名为RAW,我们在这里使用的高度图文件格式就是RAW,这个格式不包含诸如图像类型和大小信息的文件头,所以易于被读取。RAW文件只是简单的二进制文件,只包含地形的高度数据。在一个8位高度图中,每个字节都表示顶点的高度。


2.高度图的制作

高度图的制作一般有两种方式。

1、以某种算法为基础,写个程序生成。比较有名的是Fault Formation和Midpoint Displacement这两种算法。

2、通过图像编辑软件,三维建模软件,或者专业制作地形的软件来制作。

 

图像编辑软件首当其冲的当然是Photoshop,这个就是我们今天准备教大家的高度图生成方式。(先把后面两种介绍完,稍后就教大家怎么做高度图。)

三维建模软件就如我们之前介绍过的3DS Max和Maya了,地形制作也是三维建模界的一个分支。

然后专业制作地形的软件,比如一款叫Terragen。这款软件用起来也很方便,大家不妨google一下去下一个玩玩。

 

3、用Photoshop制作高度图

接下来,浅墨来教大家使用Photoshop生成高度图。

 

1.打开Photoshop(浅墨用的是Photoshop CS6),【Ctrl+N】或者依次点击菜单栏上的【文件】->【新建】,新建一个画布。如下图,我们的画布的大小取64x64像素。


2.创建完画布,接下来就是最关键的一步。依次点击菜单栏上的【滤镜】->【渲染】->【云彩】。

这时候,我们就可以发现,我们创建的空白画布上有了随机的灰度颜色值,如果你对这次生成的随机灰度图不满意的话,大可再次点击【滤镜】->【渲染】->【云彩】(或者【Ctrl+F】)来重新生成一次随机的灰度效果图,直到颜色分布满意为止。我也也可以用画笔来在图上涂抹,自己来设定高度。这是浅墨通过处理后得到的一张灰度图,这样后面如果我们用这张图作为高度图,得到的就是一个凹下去型的爱心地形图:


 

记得在用【云彩】滤镜的时候,最好把调色板的颜色前景色设为纯黑色,不然可能得到的随机灰度图效果出不来。即调色板中的颜色设置成如下图:


另外,我们可以通过对图片色阶的调整,来对生成的灰度图的整体颜色进行调节。比如想让地形整体来说高一些,就把灰度图整体调亮一些,反之,地形整体来说要显得低一些的话,就把绘图图整体调按。色阶对话框通过【图像】->【调整】->【色阶】打开,或者直接按快捷键【Ctrl+F】。

另外在点击【图像】->【调整】后弹出的对话框中还有曲线、色相、饱和度等等选项,大家不妨也试试。

制作完成,我们点击【文件】->【储存为…】或者直接按快捷键【Shift+Ctrl+S】来制作好的高度图进行保存。保存的格式随意,因为我们稍后写的一个地形类原则上支持几乎所有的图片格式高度图的导入,只不过对有些图片格式得到的效果图比较奇葩而已。

这里我们选择8位的raw格式:


点击确定后,会弹出如下导出raw的对话框,记得要把【通道储存在】这个选项改成非隔行顺序,如图:


 

其实,大家不想用Photshop的话,可以直接google一下“heighmap”,搜索结果中随便找就是一张现成的,然后改成raw格式就好了。原则上我们可以直接随便拿一张任意格式的图片来做高度图使用,只是可能做出来的地形显得怪异一点而已。

 

 

 

 

 

4.在程序中读取高度图

让我们针对使用最广泛的raw类型的高度图进行讲解。由于raw格式文件是按字节为单位保存图像中的每个像素的灰度值的,那么我们可以容易地读取保存在该文件中的高度信息。在这次的地形类的实现中,我们用到了C++中模板以及文件流的知识,如果对下面这段代码不太熟悉的话就去看看《C++Primer》的相应章节吧。好了,下面贴出详细注释的代码:

 

[cpp]  view plain   copy
  print ?
  1. // 从文件中读取高度信息  
  2.     std::ifstream inFile;      
  3.     inFile.open(pRawFileName,std::ios::binary);   //用二进制的方式打开文件  
  4.    
  5.     inFile.seekg(0,std::ios::end);                                              //把文件指针移动到文件末尾  
  6.     std::vector<BYTE>inData(inFile.tellg());                 //用模板定义一个vector类型的变量inData并初始化,其值为缓冲区当前位置,即缓冲区大小  
  7.    
  8.     inFile.seekg(std::ios::beg);                                                       //将文件指针移动到文件的开头,准备读取高度信息  
  9.     inFile.read((char*)&inData[0],inData.size());    //关键的一步,读取整个高度信息  
  10.     inFile.close();                                                                                          //操作结束,可以关闭文件了  


且由于保存在raw文件中的每个灰度数据只是用一个字节存储的,那么这样所表示的地形高度只能在[0,255]之间取值。我们显然不高兴这样。所以,我们继续将读取的高度信息重新保存到一个浮点型的模板类型中,这样就能舒心地取到任何范围的高度值了。注意下面这段代码中vHeightInfo的定义是在类头文件中的:

[cpp]  view plain   copy
  print ?
  1. std::vector<FLOAT>   m_vHeightInfo;               // 用于存放高度信息  
  2. …  
  3.  m_vHeightInfo.resize(inData.size());                                //将m_vHeightInfo尺寸取为缓冲区的尺寸  
  4.     //遍历整个缓冲区,将inData中的值赋给m_vHeightInfo  
  5.  for (unsigned int i=0; i
  6.      m_vHeightInfo[i] = inData[i];  


 

三、地形类轮廓的书写


在继续展开讲解之前,让我们先来把这个地形类的整体轮廓给勾勒出来。这个类我们取名为TerrainClass,它能通过载入二进制类型的文件(以raw格式为首)来得到地形的高度信息,通过载入图片得到地形所采用的纹理。载入文件的过程我们封装在一个名为LoadTerrainFromFile的函数中。

在上文中讲高度图的概念相关知识的时候我们就提到过,需要把高度图所传达的信息转化到顶点网格中去,这样才好绘制出来。所以在类中既是重点也是难点的就是这个“转化”的过程,这个过程我们放到一个名为InitTerrain的函数中。高度图到顶点的“转化”完成后,接下来当然需要把这些顶点配合着纹理都绘制出来,绘制的过程我们放在一个名为RenderTerrain的函数中。加上构造函数和析构函数,FVF顶点格式的定义以及若干必须的成员变量,我们就可以勾勒出TerrainClass类的轮廓如下,即下面贴出来的是Terrain.h头文件的全部代码:

[cpp]  view plain   copy
  print ?
  1. //=============================================================================  
  2. // Name: TerrainClass.h  
  3. //  Des: 一个封装了三维地形系统的类的头文件  
  4. // 2013年 3月17日  Create by 浅墨   
  5. //=============================================================================  
  6.   
  7. #pragma once  
  8.   
  9. #include   
  10. #include   
  11. #include   
  12. #include   
  13. #include  "D3DUtil.h"  
  14.   
  15. class TerrainClass  
  16. {  
  17. private:  
  18.     LPDIRECT3DDEVICE9       m_pd3dDevice;           //D3D设备  
  19.     LPDIRECT3DTEXTURE9      m_pTexture;         //纹理  
  20.     LPDIRECT3DINDEXBUFFER9  m_pIndexBuffer;         //顶点缓存  
  21.     LPDIRECT3DVERTEXBUFFER9 m_pVertexBuffer;        //索引缓存  
  22.   
  23.     int                         m_nCellsPerRow;     // 每行的单元格数  
  24.     int                         m_nCellsPerCol;         // 每列的单元格数  
  25.     int                         m_nVertsPerRow;     // 每行的顶点数  
  26.     int                         m_nVertsPerCol;         // 每列的顶点数  
  27.     int                         m_nNumVertices;     // 顶点总数  
  28.     FLOAT                       m_fTerrainWidth;        // 地形的宽度  
  29.     FLOAT                       m_fTerrainDepth;        // 地形的深度  
  30.     FLOAT                       m_fCellSpacing;         // 单元格的间距  
  31.     FLOAT                       m_fHeightScale;         // 高度缩放系数  
  32.     std::vector<FLOAT>   m_vHeightInfo;           // 用于存放高度信息  
  33.   
  34.     //定义一个地形的FVF顶点格式  
  35.     struct TERRAINVERTEX  
  36.     {  
  37.         FLOAT _x, _y, _z;  
  38.         FLOAT _u, _v;  
  39.         TERRAINVERTEX(FLOAT x, FLOAT y, FLOAT z, FLOAT u, FLOAT v)   
  40.             :_x(x), _y(y), _z(z), _u(u), _v(v) {}  
  41.         static const DWORD FVF = D3DFVF_XYZ | D3DFVF_TEX1;  
  42.     };  
  43.   
  44. public:  
  45.     TerrainClass(IDirect3DDevice9 *pd3dDevice); //构造函数  
  46.     virtual ~TerrainClass(void);        //析构函数  
  47.   
  48. public:  
  49.     BOOL LoadTerrainFromFile(wchar_t *pRawFileName, wchar_t *pTextureFile);     //从文件加载高度图和纹理的函数  
  50.     BOOL InitTerrain(INT nRows, INT nCols, FLOAT fSpace, FLOAT fScale);  //地形初始化函数  
  51.     BOOL RenderTerrain(D3DXMATRIX *pMatWorld, BOOL bDrawFrame=FALSE);  //地形渲染函数  
  52. };  


 

四、地形顶点的计算

 

下面我们来看看如何计算出地形中的每个顶点。

在计算顶点之前,还需要做一些准备工作。在创建地形时,需要通过指定地形的行数、列数以及顶点间的距离来指定地形的大小。上面我们在给类写轮廓的时候刚贴出来过,封装着地形顶点计算的InitTerrain函数的原型是这样的:

 

[cpp]  view plain   copy
  print ?
  1. BOOLInitTerrain(INT nRows, INT nCols, FLOAT fSpace, FLOAT fScale);  //地形初始化函数  

其中前两个参数分别为地形的行数和列数,需要我们在初始化时指定。也就是说在计算地形的时候行数和列数是已知的,那么,地形在x方向和z方向上的顶点数也就明了了,也就是z方向上顶点数为地形的行数加1,而在x方向上的顶点数为地形列数加上1.

需要注意的是地形在x方向和z方向上的顶点数都不能大于与高度图对应的分辨率分量。因为高度图中的每个元素都描述地形型中某个顶点的高度值。如果高度图中只描述了128x128分辨率的地形信息,我们在初始化时InitTerrain函数的前两个参数都不能取超过128的值。以高度图的角度来想一下,既然我只跟你准备了128x128的高度信息,那么你就得在我规定的范围之内取顶点数,如果你取多了,我可管不了你这么多,等着内存溢出吧。

接着,第三个fSpace为顶点间的间隔,第四个参数fScale为缩放的系数。

关于顶点的计算思路,我们通过下面这幅图,就可以写出来:

 

 

对每行的单元格数目、每列的单元格数目、单元格间的间距、高度缩放系数、地形的宽度、地形的深度、每行的顶点数、每列的顶点数、顶点总数各个击破,就写出了下面这几句代码:

[cpp]  view plain   copy
  print ?
  1. m_nCellsPerRow  = nRows; //每行的单元格数目  
  2.  m_nCellsPerCol  = nCols; //每列的单元格数目  
  3.  m_fCellSpacing  = fSpace;    //单元格间的间距  
  4.  m_fHeightScale  = fScale; //高度缩放系数  
  5.  m_fTerrainWidth = nRows * fSpace;  //地形的宽度  
  6.  m_fTerrainDepth = nCols * fSpace;  //地形的深度  
  7.  m_nVertsPerRow  = m_nCellsPerCol + 1;  //每行的顶点数  
  8.  m_nVertsPerCol  = m_nCellsPerRow + 1; //每列的顶点数  
  9.  m_nNumVertices  = m_nVertsPerRow * m_nVertsPerCol;  //顶点总数  


另外,我们在计算地形顶点前,还需要将地形的高度值乘以一个缩放系数,以便能够调整高度的整体变化幅度,就是下面这两句代码:

 

    

[cpp]  view plain   copy
  print ?
  1. // 通过一个for循环,逐个把地形原始高度乘以缩放系数,得到缩放后的高度  
  2.     for(unsigned int i=0;i
  3.         m_vHeightInfo[i] *= m_fHeightScale;  

 

 

接着,就是顶点的正式计算时刻,我们按照着之前专门讲解顶点缓存时用的四步曲,以及对着上面的这幅图,下面的这些实现代码就很好理解了:

 

[cpp]  view plain   copy
  print ?
  1. //---------------------------------------------------------------  
  2. // 处理地形的顶点  
  3. //---------------------------------------------------------------  
  4.    //1,创建顶点缓存  
  5. if(FAILED(m_pd3dDevice->CreateVertexBuffer(m_nNumVertices * sizeof(TERRAINVERTEX),  
  6.     D3DUSAGE_WRITEONLY, TERRAINVERTEX::FVF,D3DPOOL_MANAGED, &m_pVertexBuffer, 0)))  
  7.     return FALSE;  
  8.    //2,加锁  
  9. TERRAINVERTEX *pVertices = NULL;  
  10. m_pVertexBuffer->Lock(0, 0,(void**)&pVertices, 0);  
  11.    //3,访问,赋值  
  12. FLOAT fStartX = -m_fTerrainWidth / 2.0f,fEndX =  m_fTerrainWidth / 2.0f;          //指定起始点和结束点的X坐标值  
  13. FLOAT fStartZ =  m_fTerrainDepth / 2.0f, fEndZ =-m_fTerrainDepth / 2.0f;  //指定起始点和结束点的Z坐标值  
  14. FLOAT fCoordU = 3.0f /(FLOAT)m_nCellsPerRow;     //指定纹理的横坐标值  
  15. FLOAT fCoordV = 3.0f /(FLOAT)m_nCellsPerCol;           //指定纹理的纵坐标值  
  16.   
  17. int nIndex = 0, i = 0, j = 0;  
  18. for (float z = fStartZ; z > fEndZ; z -=m_fCellSpacing, i++)          //Z坐标方向上起始顶点到结束顶点行间的遍历  
  19. {  
  20.     j = 0;  
  21.     for (float x = fStartX; x < fEndX; x+= m_fCellSpacing, j++)  //X坐标方向上起始顶点到结束顶点行间的遍历  
  22.     {  
  23.         nIndex = i * m_nCellsPerRow + j;         //指定当前顶点在顶点缓存中的位置  
  24.         pVertices[nIndex] =TERRAINVERTEX(x, m_vHeightInfo[nIndex], z, j*fCoordU, i*fCoordV); //把顶点位置索引在高度图中对应的各个顶点参数以及纹理坐标赋值给赋给当前的顶点  
  25.         nIndex++;                                                                          //索引数自加1  
  26.     }  
  27. }  
  28.    //4,解锁  
  29. m_pVertexBuffer->Unlock();  


已经逐行注释了,理解起来应该是没问题的吧。

 



五、地形索引的计算

 

顶点值算完了,当然还需要接着计算顶点的索引。顶点索引的计算关键是推导出一个用于求构成第i行,第j列的顶点处右下方两个三角形的顶点索引的通用公式。下面我们来看看这个公式如何推导,下图依旧解释得很清楚了:


对顶点缓存中的任意一点A,如果该点位于地形中的第i行、第j列的话,那么该点在顶点缓存中所对应的位置应该就是i*m+j(m为每行的顶点数)。如果A点在索引缓存中的位置为k的话,那么A点为起始点构成的三角形ABC中,B、C顶点在顶点缓存中的位置就为(i+1)x m+j和i x m+(j+1)。且B点索引值为k+1,C点索引值为k+2.这样。这样,公式就可以推导为如下:

 

三角形ABC=【i*每行顶点数+j,i*每行

你可能感兴趣的:(DirectX9游戏编程,DX9游戏编程)