问题

基于一张2D高度图,你想创建一个地形并以一个有效率的方法绘制它。

解决方案

首先需要一张高度图,包含所有用来定义地形的高度数据。这张高度图有确定数量的二维数据点,我们称之为地形的宽和高。

显然,如果你想基于这些数据创建一个地形,这个地形将从这些width*height顶点中绘制,如图5-14右上角所示(注意数字是从0开始的)。

image

图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)的三角形!这个三角形会横跨第一行的整个长度,这不是你想要的结果。

image

图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)的索引,所以实际上是一条线。如果你从右边开始定义第二行,正常情况下你会从两个顶点开始,记住实际上你绘制了两个看不见的三角形。

image

图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的范围中。

image