2.10 使用四叉树隐藏不在视野中的部分网格
问题
地形绘制是游戏中的一个重要组成部分。但是,使用教程5-8中的方法,当绘制一个很大的地形时会导致帧数下降。
解决方案
你可以使用一个四叉树简化绘制大地形的工作流程,四叉树类似于八叉树,因为你要将地形分割成更小的方块直到这些方块不大于指定的大小。
这个处理过程如图2-13左图所示。一个16 × 16大小的方块被分割成四个方块,每个小方块再被分割成四个方块。
使用四叉树的主要优点是当绘制地形时,你只需绘制在相机视野中的那些方块,这可以通过检测哪些方块与相机视景体相交做到,显示在图2-13的右图中,看得见的方块用灰色表示。
图2-13 将一个大方块分割成小方块(左图),与相机视景体相交的方块(右图)
四叉树是八叉树的简化版本。只不过一个方块只需要被分割成四个小方块,而一个八叉树节点需要被分割成八个子立方体。八叉树需要保存其中的所有物体的位置,而四叉树无需进行这个操作。
工作原理
创建一个新的类表示一个方块,即四叉树的一个节点:
namespace BookCode { class QTNode { private BoundingBox nodeBoundingBox; private bool isEndNode; private QTNode nodeUL; private QTNode nodeUR; private QTNode nodeLL; private QTNode nodeLR; private int width; private int height; private GraphicsDevice device; private BasicEffect basicEffect; private VertexBuffer nodeVertexBuffer; private IndexBuffer nodeIndexBuffer; private Texture2D grassTexture; public static int NodesRendered; } }
下面讨论一下每个节点需要的变量。第一个是包围盒,它非常重要。它是最小的盒子,当前节点的所有顶点都在这个盒子中。每一帧中你都要检测当前盒子是否在相机的视野中,只有结果为true才会在Draw方法中绘制这个节点中的所有子节点。
当创建一个四叉树时,你必须指定节点的最大大小。变量isEndNode表示当前节点是否大于这个最大大小,如果大于,这个节点必须创建四个子节点,存储在nodeUL到nodeLR中,其中UL表示左上方的节点,LR表示右下方的节点。
如果当前节点没有子节点,当isEndNode为true时这个节点会绘制某些三角形,要进行这个操作,你需要知道它的长和宽。你还需要一个指向图形设备和BasicEffect的链接。此外,你还需要一个VertexBuffer、一个IndexBuffer和一个Texture用来绘制三角形(可参见教程5-8)。
调用节点的构造函数创建一个新节点:
public QTNode(VertexPositionNormalTexture[,] vertexArray, GraphicsDevice device, Texture2D grassTexture, int maxSize) { this.device = device; this.grassTexture = grassTexture; basicEffect = new BasicEffect(device, null); width = vertexArray.GetLength(0); height = vertexArray.GetLength(1); nodeBoundingBox = CreateBoundingBox(vertexArray); isEndNode = width <= maxSize; isEndNode &= height <= maxSize; if (isEndNode) { VertexPositionNormalTexture[] vertices = Reshape2Dto1D<VertexPositionNormalTexture>(vertexArray); int[] indices = TerrainUtils.CreateTerrainIndices(width, height); TerrainUtils.CreateBuffers(vertices, indices, out nodeVertexBuffer, out nodeIndexBuffer, device); } else { CreateChildNodes(vertexArray, maxSize); } }
一个节点需要一个2D数组包含所有的顶点,还有指向设备和纹理的链接,一个大小,当节点的大小小于这个大小时就会停止将自己分割成子节点。
指向设备和纹理的链接会立即存储在节点中。当前节点的长和宽从包含顶点的2D数组中获取。给定这个节点的所有顶点,你可以使用下面就有创建的CreateBoundingBox方法计算包围盒。
最后,你检查当前节点的宽和高是否小于最大大小。如果是,则从节点的顶点中创建一个VertexBuffer和一个IndexBuffer。如果大于最大大小,则将这个节点分割成四个子节点。
创建包围盒
给定包含顶点的2D数组,你可以很容易地将这些位置存储在一个集合中。BoundingBox 类的CreateFromPoints方法可以从位置集合中生成一个包围盒:
private BoundingBox CreateBoundingBox(VertexPositionNormalTexture[,] vertexArray) { List<Vector3> pointList = new List<Vector3>(); foreach (VertexPositionNormalTexture vertex in vertexArray) pointList.Add(vertex.Position); BoundingBox nodeBoundingBox = BoundingBox.CreateFromPoints(pointList); return nodeBoundingBox; }
生成VertexBuffer和IndexBuffer
你可以使用教程5-8中的CreateVertices和CreateIndices方法,这两个方法根据给定的1D数组创建了一个VertexBuffer和一个IndexBuffer。这需要你首先将2D数组转换为一个1D数组,可以使用如下方法:
private T[] Reshape2Dto1D<T>(T[,] array2D) { int width = array2D.GetLength(0); int height = array2D.GetLength(1); T[] array1D = new T[width * height]; int i = 0; for (int z = 0; z < height; z++) for (int x = 0; x < width; x++) array1D[i++] = array2D[x, z]; return array1D; }
这个泛型方法接受一个类型为T(本例中为VertexPositionNormalTexture)的2D数组,获取它的大小,并将它的内容复制到一个1D数组中。
注意:泛型功能是在.NET 2.0中引入的,当调用一个泛型方法时,你需要在括号内指定需要用哪种类型替换T,如下所示:
VertexPositionNormalTexture[] vertices = Reshape2Dto1D<VertexPositionNormalTexture>(vertexArray);
将节点分割成四个子节点
如果节点太大,它应该被分割成四个子节点。这里的难点是所有的子节点不都总是一样大小的。在图2-13的左图中,你看到一个16 × 16网格被分成四个8 × 8网格。如果每个子节点只存储8 × 8个顶点,就无法绘制在第8和第9行(列)之间的三角形,会留下空隙。在图2-13中,可以通过让第一行(列)的左上角节点大于其他节点解决这个问题。
对于不成对的方块,这个问题不存在:9 × 9方块可以被分成四个包含5 × 5顶点的方块,如图2-13的左上角方块所示。
剩下的问题是,你如何计算存储在每个方块中的顶点数量?比如,你怎么知道16应该分成9和8,而9分成5和5?第一个值可以将父节点的大小除以2获得,取小的整数(除以一个整数获得的结果就是你所期望的)再将结果加1。第二个值可以将父节点的大小除以2,取小的整数,然后从父节点的大小中减去这个结果。
例如,16除以2为8,加1为9,即第一个值。然后16除以2为8,16减8为8。
第二个例子中:9除以2为4.5,取4再加1为5,即第一个值。然后,9除以2为4.5,取4,将9减去4为5。
知道了子节点的大小,你就可以使用下列代码创建子节点了。对每个子节点,你首先复制顶点的大小,创建一个QTNode对象:
private void CreateChildNodes(VertexPositionNormalTexture[,] vertexArray, int maxSize) { VertexPositionNormalTexture[,] ulArray = new VertexPositionNormalTexture[width / 2 + 1, height / 2 + 1]; for (int w = 0; w < width / 2 + 1; w++) for (int h = 0; h < height / 2 + 1; h++) ulArray[w, h] = vertexArray[w, h]; nodeUL = new QTNode(ulArray, device, grassTexture, maxSize); VertexPositionNormalTexture[,] urArray = new VertexPositionNormalTexture[width - (width / 2), height / 2 + 1]; for (int w = 0; w < width - (width / 2); w++) for (int h = 0; h < height / 2 + 1; h++) urArray[w, h] = vertexArray[width / 2 + w, h]; nodeUR = new QTNode(urArray, device, grassTexture, maxSize); VertexPositionNormalTexture[,] llArray = new VertexPositionNormalTexture[width / 2 + 1, height - (height / 2)]; for (int w = 0; w < width / 2 + 1; w++) for (int h = 0; h < height - (height / 2); h++) llArray[w, h] = vertexArray[w, height / 2 + h]; nodeLL = new QTNode(llArray, device, grassTexture, maxSize); VertexPositionNormalTexture[,] lrArray = new VertexPositionNormalTexture[width - (width / 2), height - (height / 2)]; for (int w = 0; w < width - (width / 2); w++) for (int h = 0; h < height - (height / 2); h++) lrArray[w, h] = vertexArray[width / 2 + w, height / 2 + h]; nodeLR = new QTNode(lrArray, device, grassTexture, maxSize); }
绘制四叉树
实现了创建和分割功能后,你就做好了绘制四叉树的准备。你需要确保只绘制在相机视野中的方块。在主程序中,你需要只调用四叉树的根节点的Draw方法绘制所有在相机视野中的节点。
需要检查根节点是否在相机视野中。如果不是,无需进行任何操作。如果是,这个根节点需要传递到四个子节点的Draw调用中。
每个子节点进行同样的操作:检测是否在相机视野中,如果是,将它们传递到他们的子节点的调用中直至最小的节点也被绘制。如果在视野中,会从它们的顶点中绘制一个网格:
public void Draw(Matrix worldMatrix, Matrix viewMatrix, Matrix projectionMatrix, BoundingFrustum cameraFrustum) { BoundingBox transformedBBox = XNAUtils.TransformBoundingBox(nodeBoundingBox, worldMatrix); ContainmentType cameraNodeContainment = cameraFrustum.Contains(transformedBBox); if (cameraNodeContainment != ContainmentType.Disjoint) { if (isEndNode) { DrawCurrentNode(worldMatrix, viewMatrix, projectionMatrix); nodeUL.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum); nodeUR.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum); nodeLL.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum); nodeLR.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum); } } }
这个方法需要传递相机的视景体,这个视景体用来检测相机是否与节点的包围盒相交,表示节点是否在相机视野中。
如果这个调用到达了最后一个节点,会调用DrawCurrentNode方法,这个方法会绘制指定节点。这个代码来自于教程5-8:
private void DrawCurrentNode(Matrix worldMatrix, Matrix viewMatrix, Matrix projectionMatrix) { basicEffect.World = worldMatrix; basicEffect.View = viewMatrix; basicEffect.Projection = projectionMatrix; basicEffect.Texture = grassTexture; basicEffect.VertexColorEnabled = false; 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(nodeVertexBuffer, 0, VertexPositionNormalTexture.SizeInBytes); device.Indices = nodeIndexBuffer; device.VertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, width * height, 0, (width * 2 * (height - 1) - 2)); pass.End(); } basicEffect.End(); NodesRendered++; //XNAUtils.DrawBoundingBox(nodeBoundingBox, device, basicEffect, worldMatrix, viewMatrix, projectionMatrix); }
每次当代码执行时,NodeRendered变量会加1。因为它是一个静态变量,被四叉树的所有节点共享,所以在绘制过程的最后,它会包含实际绘制的节点的数量。
你可以注释掉最后一行代码,它可以绘制所有被绘制的节点的包围盒对象的边框。
初始化四叉树
完成了QTNode类后,你就做好了创建四叉树的准备。大部分代码与教程5-8中的相似,从一个包含高度数据的2D纹理开始,然后创建一个VertexPositionNormalTexture元素的1D数组。因为你想使用GenerateNormalsFromTriangleStrip方法,解释请见教程5-7,需要添加正确的法线,首先需要创建一个索引集合将下列代码添加到LoadContent方法中:
Texture2D grassTexture = content.Load<Texture2D>("grass"); Texture2D heightMap = content.Load<Texture2D>("heightmap"); int width = heightMap.Width; int height = heightMap.Height; float[,] heightData = TerrainUtils.LoadHeightData(heightMap); VertexPositionNormalTexture[] vertices = ~ TerrainUtils.CreateTerrainVertices(heightData); int[] indices = TerrainUtils.CreateTerrainIndices(width, height); vertices = TerrainUtils.GenerateNormalsForTriangleStrip(vertices, indices); VertexPositionNormalTexture[,] vertexArray =Reshape1Dto2D<VertexPositionNormalTexture>(vertices, width, height); rootNode = new QTNode(vertexArray, device, grassTexture, 64);
最后你获得一个1D的顶点数组。但是QTNode的构造函数需要一个2D数组,所以最后一行代码调用Reshape1Dto2D方法:
private T[,] Reshape1Dto2D<T>(T[] vertices, int width, int height) { T[,] vertexArray = new T[width, height]; int i=0; for (int h = 0; h < height; h++) for (int w = 0; w < width; w++) vertexArray[w, h] = vertices[i++]; return vertexArray; }
这仍是一个泛型方法,让你可以将1D数组转换为一个2D数组。有了顶点的2D数组,将最后一行代码添加到LoadContent方法中:
rootNode = new QTNode(vertexArray, device, grassTexture, 64);
这行代码生成整个四叉树。你传入一个顶点2D数组和大小为64的最大大小,只要方块的大小大于64,它们就会被分割成子节点。
使用四叉树
在XNA项目中,你可以在Draw方法中放置以下代码:
QTNode.NodesRendered = 0; BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); rootNode.Draw(Matrix.CreateTranslation(-250,-20,250), fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum); Window.Title = string.Format("{0} nodes rendered", QTNode.NodesRendered);
第一行和最后一行代码只用于调试,因为它们会将实际绘制的方块数量显示在窗口的标题栏中。
第二行代码创建了相机的视景体,用于检测四叉树的节点是否在相机视野中。第三行代码初始化Draw调用,Draw方法会遍历所有节点并绘制可见的节点。
代码
你可以在前面看到QTNode类的所有代码,下面是XNA主项目中的LoadContent方法:
protected override void LoadContent() { device = graphics.GraphicsDevice; cCross = new CoordCross(device); Texture2D grassTexture = content.Load<Texture2D>("grass"); Texture2D heightMap = content.Load<Texture2D>("heightmap"); int width = heightMap.Width; int height = heightMap.Height; float[,] heightData = TerrainUtils.LoadHeightData(heightMap); VertexPositionNormalTexture[] vertices = TerrainUtils.CreateTerrainVertices(heightData); int[] indices = TerrainUtils.CreateTerrainIndices(width, height); vertices = TerrainUtils.GenerateNormalsForTriangleStrip(vertices, indices); VertexPositionNormalTexture[,] vertexArray =Reshape1Dto2D<VertexPositionNormalTexture>(vertices, width, height); rootNode = new QTNode(vertexArray, device, grassTexture, 64); }
Draw方法会绘制所有可见节点:
protected override void Draw(GameTime gameTime) { graphics.GraphicsDevice.Clear(Color.CornflowerBlue); cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix); QTNode.NodesRendered = 0; BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); rootNode.Draw(Matrix.CreateTranslation(-250,-20,250), fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum); Window.Title = string.Format("{0} nodes rendered", QTNode.NodesRendered); base.Draw(gameTime); }
扩展阅读
这个教材介绍了四叉树的基本知识,但这里用到的四叉树有几个缺点:
- 加载时间过长。
- DrawPrimitives调用次数很大,拖慢了程序。
- 如相机在地形上平视,仍会绘制大量的方块。
以上问题有多个解决方法,我没法一一列举,这样可能又可以写一本书了。但是,我会讨论产生这些问题的原因。
加载时间过长是由每次将一个方块分割成四个小方块时将顶点复制到一个小数组中引起的,重建过程会导致巨大的开销。这个问题可以通过在内容管道中生成四叉树加以解决,这样在运行过程中只需从二进制文件中读取顶点缓冲和索引缓冲流。可见教程5-13学习如何(反)串行化地形。
第二个问题的原因是显卡喜欢进行持续的工作不要被打搅,一次绘制一百万的三角形比1000次绘制1000个三角形好得多。你可以通过减少DrawPrimitives的调用次数解决这个问题。一个方法是检测一个父节点的所有四个子节点是否都可见,如果都可见,那么绘制父节点而不是四个子节点,这样绘制的三角形数量是一样的,但只需调用DrawPrimitives一次而不是四次。
最后一个问题会导致绘制极大数量的三角形,因为离开相机非常远的方块也会绘制与离相机非常近的方块相同数量的三角形,你可以通过以低细节绘制远处的方块解决这个问题,就是level-of-detail (LOD)算法,它是一个挑战。
另一个处理巨大地形的方法可参见下一个教程,请记住你可以组合这两个教程的技术,因为大多数地形引擎使用四叉树、ROAM引擎或两者的组合。例如,你可以将地形分成小块(patch),使用四叉树控制这些小块,然后使用下一个教程的ROAM算法绘制它们。这样就组合了四叉树的控制和ROAM的算法。