问题
基于一张2D高度图,你想创建一个地形并以一个有效率的方法绘制它。
解决方案
首先需要一张高度图,包含所有用来定义地形的高度数据。这张高度图有确定数量的二维数据点,我们称之为地形的宽和高。
显然,如果你想基于这些数据创建一个地形,这个地形将从这些width*height顶点中绘制,如图5-14右上角所示(注意数字是从0开始的)。
图5-14 地形网格
要使用三角形完全覆盖网格,你需要在每个网格的四个点中绘制两个三角形,如图5-14所示。一行需要(width-1)*2个三角形,整个地形需要(height-1)*(width-1)*2个三角形。
如果你想判断是否需要使用索引,可参见教程5-3的规则。本教程的情况中,(三角形数量) 除以(顶点数)小于1,所以应该使用索引。所有不在边界上的顶点会被不少于六个的三角形共享。
此外,因为所有三角形至少共享一条边,你应该使用TriangleStrip而不是TriangleList。
工作原理
定义顶点
首先定义顶点。下面的方法首先访问heightData变量,这个变量是一个包含地形所有顶点高度的2维数组。如果你还没有这样一个数组,本教程最后的LoadHeightData方法会基于一张2D图像创建一个。
private VertexPositionNormalTexture[] CreateTerrainVertices() { int width = heightData.GetLength(0); int height = heightData.GetLength(1); VertexPositionNormalTexture[] terrainVertices = new VertexPositionNormalTexture[width * height]; int i = 0; for (int z = 0; z < height; z++) { for (int x = 0; x < width; x++) { Vector3 position = new Vector3(x, heightData[x, z], -z); Vector3 normal = new Vector3(0, 0, 1); Vector2 texCoord = new Vector2((float)x / 30.0f, (float)z / 30.0f); terrainVertices[i++] = new VertexPositionNormalTexture(position, normal, texCoord); } } return terrainVertices; }
首先基于heightData数组的大小获取地形的高和宽。然后创建一个数组保存所有顶点。如前所述,地形需要width*height个顶点。
接着在两个循环中国创建所有顶点。里面的一个循环创建一行上的顶点,当一行完成后,第一个for循环切换到下一行,直到定义完所有行的顶点。
你使用X和Z坐标作为循环的计数器,z值是负的,因此地形是建立在向前(-Z)方向的。而高度信息取自heightData数组。
现在你给与所有顶点一个默认的法线方向,这个方向马上就会使用前一个教程的方法替换成正确的方向。因为你可能要在地形上加上纹理,所以需要指定正确的纹理坐标。根据纹理,你想控制它在地形上的大小。这个例子中将除以30,表示纹理每经过30个顶点重复一次。如果你想增大纹理匹配更大的地形,可以除以一个更大的数值。
有了这些数据,就做好了创建这些新顶点并存储到数组中的准备。
定义索引
定义了顶点后,你就做好了通过定义索引数组构建三角形的准备(见教程5-3)。你将以TriangleStrip定义三角形,基于一个索引及它前两个索引表示一个三角形。
图5-16显示了如何使用TriangleStrip绘制三角形。数组中的第一个索引指向顶点0和W。然后对行中的每个顶点,添加这个顶点和下一行对应的顶点,直到到达行一行的最后。。这时,你要定义2*width个索引,对应(2*width-2)个三角形,足够覆盖整个行。
但是你只定义了第一行,你没法使用这个方法绘制第二行,这是因为你是基于前三个索引定义的三角形添加的每个索引的。基于这点,你定义的最后一个索引指向顶点(2*W-1)。如果你再次从第二行开始,会从添加一个到顶点W的索引开始,如图5-15左图所示。但是,这回定义一个基于顶点W, (2*W-1)和(W-1)的三角形!这个三角形会横跨第一行的整个长度,这不是你想要的结果。
图5-15 使用TriangleStrip定义三角形的错误方式
你可以通过从右边开始定义第二行解决这个问题。但是,简单地从最后一个索引开始不是一个好主意,因为两行的三角形的长边有不同的方向,如教程5-9中的解释,你想让三角形有相同的朝向。
图5-16显示了如何解决这个问题。在指向顶点(2*W-1)的索引后,你将立即添加一个指向相同顶点的索引!这会添加一个基于顶点(W-1)和两个顶点(2*W-1)的三角形,只会形成一条位于顶点(W-1)和(2*W-1)之间的一条线,所以这个三角形不可见,叫做ghost三角形。接下来,添加一个指向顶点(3*W-1)的索引,因为这个三角形基于两个指向相同顶点(2*W-1)的索引,所以实际上是一条线。如果你从右边开始定义第二行,正常情况下你会从两个顶点开始,记住实际上你绘制了两个看不见的三角形。
图5-16 使用TriangleStrip定义三角形的正确方式
注意:你可能认为无需添加第二个指向(2*W-1)的索引,可以立即将一个索引添加到(3*W-1)中。但是,基于两个理由需要额外的指向顶点(2*W-1)的索引。首先,如果你没有添加这个索引,那么只有一个三角形被添加,你会被TriangleStrip方式所需的绕行方向的反转所干扰。第二,这会添加一个基于(3*W-1), (2*W-1)和 (W-1)的三角形,如果三个顶点高度不相同那么这个三角形会被显示。
下面是生成索引的方法:
private int[] CreateTerrainIndices() { int width = heightData.GetLength(0); int height = heightData.GetLength(1); int[] terrainIndices = new int[(width)*2*(height-1)]; int i = 0; int z = 0; while (z < height-1) { for (int x = 0; x < width; x++) { terrainIndices[i++] = x + z * width; terrainIndices[i++] = x + (z + 1) * width; } z++; if (z < height-1) { for (int x = width - 1; x >= 0; x--) { terrainIndices[i++] = x + (z + 1) * width; terrainIndices[i++] = x + z * width; } } z++; } return terrainIndices; }
首先创建一个数组,存储地形所需的所有索引。如教程5-16所示,每行需要定义width*2个三角形。在本例中,你有三行顶点,但只绘制两行三角形,所以需要width*2*(height-1) 索引。
前面代码中的z值表示当前行。你从左向右创建第一行,然后,增加z,表示切换到下一行。第二行从右向左创建,如图5-16所示,z值仍然增加。这个程序放在while循环中,直到所有偶数行从左向右建立,奇数行从右向左建立。
当z变为height-1时while循环结束,返回结果数组。
法线,顶点缓冲和索引缓冲
你要创建法线数据,通过创建一个顶点缓冲和一个索引缓冲将这些数据发送到显卡,然后绘制三角形。
在LoadContents方法中添加以下代码:
myVertexDeclaration=new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); VertexPositionNormalTexture[] terrainVertices = CreateTerrainVertices(); int[] terrainIndices = CreateTerrainIndices(); terrainVertices = GenerateNormalsForTriangleStrip(terrainVertices, terrainIndices); CreateBuffers(terrainVertices, terrainIndices);
第一行代码用来告知显卡每个顶点包含位置,法线和纹理坐标的数据。我已近讨论过下面两个方法:它们生成所有顶点和索引。GenerateNormalsForTriangleStrip方法在教程5-7,它将法线数据添加到顶点中使地形光照正确。最后的方法将数据发送到显卡:
private void CreateBuffers(VertexPositionNormalTexture[] vertices, int[] indices) { terrainVertexBuffer = new VertexBuffer(device, VertexPositionNormalTexture.SizeInBytes * vertices.Length,BufferUsage.WriteOnly); terrainVertexBuffer.SetData(vertices); terrainIndexBuffer = new IndexBuffer(device, typeof(int), indices.Length, BufferUsage.WriteOnly); terrainIndexBuffer.SetData(indices); }
你可以在教程5-3中找到所有方法和使用参数的解释。
把数据发送到显卡后,现在可以绘制地形了。代码的第一部分设置BasicEffect (包含光照,见教程6-1)的变量,所以在Draw方法中添加以下代码:
int width = heightData.GetLength(0); int height = heightData.GetLength(1); basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = grassTexture; basicEffect.TextureEnabled = true; basicEffect.EnableDefaultLighting(); basicEffect.DirectionalLight0.Direction =new Vector3(1, -1, 1); basicEffect.DirectionalLight0.Enabled = true; basicEffect.AmbientLightColor = new Vector3(0.3f, 0.3f, 0.3f); basicEffect.DirectionalLight1.Enabled = false; basicEffect.DirectionalLight2.Enabled = false; basicEffect.SpecularColor = new Vector3(0, 0, 0);
要将一个3D场景绘制到2D屏幕上,需要设置World,View和Projection矩阵(见教程2-1和4-2)。然后指定纹理。第二个代码块设置一个定向光(如教程6-1所示)。关闭镜面高光(见教程6-4),这是因为草地地形没有闪亮的材质。
设置了effect后就可以绘制三角形了。这个代码从一个索引TriangleStrip绘制三角形,解释请见教程5-3:
basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.Vertices[0].SetSource(terrainVertexBuffer, 0,VertexPositionNormalTexture.SizeInBytes); device.Indices = terrainIndexBuffer; device.VertexDeclaration = myVertexDeclaration; device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, width * height, 0, width * 2 * (height - 1) - 2); pass.End(); } basicEffect.End();
首先将VertexBuffer和IndexBuffer作为显卡的当前缓冲。VertexDeclaration表明GPU需要何种数据,在数据流中的哪儿获取必要信息。DrawIndexedPrimitives绘制TriangleStrip,这需要处理所有width*height个顶点,绘制总共width*2*(height-1)-2个三角形。要获取最后一个值,需要查询索引数组中的索引总数。因为你是从一个TriangleStrip进行绘制的,所以顶点的总数为这个值减2。
代码
LoadContent方法中的最后四行代码生成所有索引和对应的顶点。法线数据被添加到顶点,最终的数据存储在顶点缓冲和索引缓冲中。注意LoadHeightMap方法会在后面讨论:
protected override void LoadContent() { device = graphics.GraphicsDevice; basicEffect = new BasicEffect(device, null); cCross = new CoordCross(device); Texture2D heightMap = Content.Load<Texture2D>("heightmap"); heightData = LoadHeightData(heightMap); grassTexture = Content.Load<Texture2D>("grass"); myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); VertexPositionNormalTexture[] terrainVertices = CreateTerrainVertices(); int[] terrainIndices = CreateTerrainIndices(); terrainVertices = GenerateNormalsForTriangleStrip(terrainVertices, terrainIndices); CreateBuffers(terrainVertices, terrainIndices); }
在下列方法中创建顶点:
private VertexPositionNormalTexture[] CreateTerrainVertices() { int width = heightData.GetLength(0); int height = heightData.GetLength(1); VertexPositionNormalTexture[] terrainVertices = new VertexPositionNormalTexture[width * height]; int i = 0; for (int z = 0; z < height; z++) { for (int x = 0; x < width; x++) { Vector3 position = new Vector3(x, heightData[x, z], -z); Vector3 normal = new Vector3(0, 0, 1); Vector2 texCoord = new Vector2((float)x / 30.0f, (float)z / 30.0f); terrainVertices[i++] = new VertexPositionNormalTexture(position, normal, texCoord); } } return terrainVertices; }
在下列方法中创建索引:
private int[] CreateTerrainIndices() { int width = heightData.GetLength(0); int height = heightData.GetLength(1); int[] terrainIndices = new int[(width) * 2 * (height - 1)]; int i = 0; int z = 0; while (z < height - 1) { for (int x = 0; x < width; x++) { terrainIndices[i++] = x + z * width; terrainIndices[i++] = x + (z + 1) * width; } if (z < height - 1) { for (int x = width - 1; x >= 0; x--) { terrainIndices[i++] = x + (z + 1) * width; terrainIndices[i++] = x + z * width; } } z++; } return terrainIndices; }
GenerateNormalsForTriangleStrip方法将法线数据添加到顶点中,而CreateBuffers方法 将数据储存到显卡中:
private void CreateBuffers(VertexPositionNormalTexture[] vertices, int[]indices) { terrainVertexBuffer = new VertexBuffer(device, VertexPositionNormalTexture.SizeInBytes * vertices.Length, BufferUsage.WriteOnly); terrainVertexBuffer.SetData(vertices); terrainIndexBuffer = new IndexBuffer(device, typeof(int), indices.Length, BufferUsage.WriteOnly); terrainIndexBuffer.SetData(indices); }
最后,在Draw方法中地形以TriangleStrip方式绘制:
protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0); cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix); //draw terrain int width = heightData.GetLength(0); int height = heightData.GetLength(1); basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = grassTexture; basicEffect.TextureEnabled = true; basicEffect.EnableDefaultLighting(); basicEffect.DirectionalLight0.Direction = new Vector3(1, -1, 1); basicEffect.DirectionalLight0.Enabled = true; basicEffect.AmbientLightColor = new Vector3(0.3f, 0.3f, 0.3f); basicEffect.DirectionalLight1.Enabled = false; basicEffect.DirectionalLight2.Enabled = false; basicEffect.SpecularColor = new Vector3(0, 0, 0); basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.Vertices[0].SetSource(terrainVertexBuffer,0, VertexPositionNormalTexture.SizeInBytes); device.Indices = terrainIndexBuffer; device.VertexDeclaration = myVertexDeclaration; device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, width * height,0, width * 2 * (height - 1) - 2); pass.End(); } basicEffect.End(); base.Draw(gameTime); }
从一张图像读取heightData数组
大多数情况中,你并不想手动地指定heightData数组,而是从一张图像加载。这个方法加载一张图像,将每个像素的颜色映射为高度值:
private void LoadHeightData(Texture2D heightMap) { float minimumHeight = 255; float maximumHeight = 0; int width = heightMap.Width; int height = heightMap.Height; Color[] heightMapColors = new Color[width * height]; heightMap.GetData<Color>(heightMapColors); heightData = new float[width, height]; for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) { heightData[x, y] = heightMapColors[x + y * width].R; if (heightData[x, y] < minimumHeight) minimumHeight = heightData[x, y]; if (heightData[x, y] > maximumHeight) maximumHeight = heightData[x, y]; } for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) heightData[x, y] = (heightData[x, y] - minimumHeight) / (maximumHeight - minimumHeight) * 30.0f; }
第一部分将每个像素的红色通道的强度存储在heightData数组中。后面的代码重新缩放每个值,这样可以让数组中的值介于0到30的范围中。