问题
给定三维空间中的一个基点集合,你想创建一条通过所有点的赛道。你想创建顶点,计算法线,在赛道上贴上纹理。
解决方案
你可以经过几个步骤从3D点的集合创建一个赛道。首先使用教程5-16中讨论的3维Catmull-Rom插值生成位于你预定义的点之间的样条上的额外点。第一步可用图5-33中的从“a”指向“b的箭头表示”。
基于这些点无法定义三角形。对样条上的每个点,你要计算垂直于样条的方向,在样条两侧各添加一个点,如图5-33中的“b”所示。
图5-33 为赛道生成顶点
最后,通过将这些新计算的点转换为顶点创建一个TriangleList,如图5-36的“d”所示。
工作原理
首先需要定义一些3D点。例如下面的集合定义了赛道类似于8的形状:
List<Vector3> trackPoints = new List<Vector3>(); trackPoints.Add(new Vector3(2, 0, 4)); trackPoints.Add(new Vector3(0, 0, 0)); trackPoints.Add(new Vector3(-2, 0, -4)); trackPoints.Add(new Vector3(2, 0, -4)); trackPoints.Add(new Vector3(0, 1, 0)); trackPoints.Add(new Vector3(-2, 0, 4));
注意:最后一个点比其他点高一个单位,所以你没必要将红绿灯放置在赛道上。
计算基点之间的额外点
首先使用Catmull-Rom插值计算基点间的许多额外点,如图5-34的”b”所示。在教程5-16中创建的InterpolateCR方法很有用,因为它可以计算任意两个基点间的额外点。
但是,要创建两个基点间的额外点你还需要提供两个邻近的基点。例如,如果你想计算图5-34中点1和点2间的额外点,需要将点0, 1, 2,3传递到InterpolateCR方法中。
图5-34 使用Catmull-Rom的头尾连接
通常来说,如果你想计算基点i和i+1之间的额外点,你需要提供点i-1, i,i+1和i+2。这在赛道末端会导致一个问题,如图5-34所示。这个赛道由八个点组成,你可以毫无问题地构建位于[1,2], [2,3], [3,4], [4,5]和[5,6]之间的部分。但是,当计算[6,7]间的额外点时,你需要传递点5, 6, 7和8。但因为集合中只包含基点0到7,这里就会出现问题。更坏的是,要计算最后的7到0,0到1两段时,你还需要基点9和10,它们也不存在。
幸运的是,你知道赛道的最后一点连接到第一点,所以你知道基点8就是基点0。同样的道理,基点9就是基点1,基点10就是基点2。这意味着你可以将基点0, 1和2添加到集合尾部来解决这个问题。这一步需要在GenerateTrackPoints方法的一开始进行,这个方法会创建整个赛道的所有额外点的集合:
private List<Vector3> GenerateTrackPoints(List<Vector3> basePoints) { basePoints.Add(basePoints[0]); basePoints.Add(basePoints[1]); basePoints.Add(basePoints[2]); List<Vector3> allPoints = new List<Vector3>(); for (int i = 1; i < basePoints.Count-2; i++) { List<Vector3> part = InterpolateCR(basePoints[i - 1], basePoints[i], basePoints[i + 1], basePoints[i + 2]); allPoints.AddRange(part); } allPoints.Add(allPoints[0]); return allPoints; }
将前三个基点复制到点集合的尾部后,你创建了一个新的空集合,这个集合包含了赛道的所有中心点,对应图5-33中的“b”。
在for循环中会从一段跳到下一段,使用InterpolateCR方法创建一段的额外点,并将所有点都添加到allPoints集合中。
对每一段,for循环会调用InterpolateCR方法,传递基点i-1,i,i+1和i+2,i从1开始。这意味着第一段从[ 1,2]开始,如图5-34左图所示。InterpolateCR方法会返回基点1和2之间的基点1和19个额外点。这20个点会被添加到allPoints集合中。
最后一段会被添加到[8,9]之间,与[0,1]相同。
注意:你可以通过InterpolateCR 方法中的detail 变量调整额外点的数量。
for循环会继续直到所有部分的所有额外点都被添加到allPoints集合中。
现在你知道了赛道的所有中心点,如图5-33的“b”所示。
计算外部的side点
对每个点你想在赛道的两边各定义一个新的点,如图5-33中“c”,这样你可以将它们连接在一起定义三角形。
要做到这一点,首先需要计算每个点的side方向。Side方向垂直于汽车行驶的方向和垂直于赛道的法线方向。这里(0,1,0) Up向量作为赛道上每个点的法线方向。
如果知道两个方向,你可以叉乘这两个方向获取垂直于这两个方向的方向。本例中,这两个方向是(0,1,0) Up方向和汽车的行驶方向,行驶方向是从当前点指向下一点的方向,你可以通过将当前点减去下一个点获取这个方向:
Vector3 carDir = basePoints[i + 1] - basePoints[i]; Vector3 sideDir =Vector3.Cross(new Vector3(0, 1, 0), carDir); sideDir.Normalize(); Vector3 outerPoint = basePoints[i] + sideDir * halfTrackWidth; Vector3 innerPoint = basePoints[i] - sideDir * halfTrackWidth;
获取了side方向,你将它乘以赛道宽度并加/减当前点。这时,你就计算了图5-33 中“c”的side点。
创建顶点
在为这些点创建顶点前,你需要提供它们的法线和纹理坐标。这里你将(0,1,0) Up向量作为法线方向。
纹理坐标有点难。如果你在每个顶点的Y坐标上添加了一个常量,那么在基点位置互相接近的位置的纹理会缩在一起。这是因为InterpolateCR方法会在任何情况下都要添加20个额外点,无论是两个基点非常靠近还是远离。
要解决这个问题,需要保存将两点间的距离保存在一个distance变量中,比较长的部分要比比较短的部分增加更多的Y坐标:
VertexPositionNormalTexture vertex; vertex = new VertexPositionNormalTexture(innerPoint, new Vector3(0, 1, 0), new Vector2(0, distance / textureLength)); verticesList.Add(vertex); vertex = new VertexPositionNormalTexture(outerPoint, new Vector3(0, 1, 0), new Vector2(1, distance / textureLength)); verticesList.Add(vertex); distance += carDir.Length();
注意:d值在处理好赛道上每个点之后才增加。纹理的Length变量让你可以缩放赛道纹理。你需要对赛道的每个中心点都进行这样的操作:
private VertexPositionNormalTexture[] GenerateTrackVertices(List<Vector3> basePoints) { float halfTrackWidth = 0.2f; float textureLength = 0.5f; float distance = 0; List<VertexPositionNormalTexture> verticesList = new List<VertexPositionNormalTexture>(); for (int i = 1; i < basePoints.Count-1; i++) { Vector3 carDir = basePoints[i + 1] - basePoints[i]; Vector3 sideDir = Vector3.Cross(new Vector3(0, 1, 0), carDir); sideDir.Normalize(); Vector3 outerPoint = basePoints[i] + sideDir * halfTrackWidth; Vector3 innerPoint = basePoints[i] - sideDir * halfTrackWidth; VertexPositionNormalTexture vertex; vertex = new VertexPositionNormalTexture(innerPoint, new Vector3(0, 1, 0), new Vector2(0, distance / textureLength)); verticesList.Add(vertex); vertex = new VertexPositionNormalTexture(outerPoint, new Vector3(0, 1, 0), new Vector2(1, distance / textureLength)); verticesList.Add(vertex); istance += carDir.Length(); } VertexPositionNormalTexture extraVert = verticesList[0]; extraVert.TextureCoordinate.Y = distance / textureLength; verticesList.Add(extraVert); extraVert = verticesList[1]; extraVert.TextureCoordinate.Y = distance / textureLength; verticesList.Add(extraVert); return verticesList.ToArray(); }
for循环之后,verticesList包含了赛道每个中心点的两个顶点。但是,当你从顶点集合绘制三角形时,仍会在最后一个中心点和第一个中心点之间出现缝隙。要连接这个缝隙,你需要将前两个中心点的side点复制到集合中。但是,因为前两个顶点的Y坐标为0,你需要将它们调整到当前的纹理坐标值。否则,最后两个三角形将会将它们的Y坐标回到0,导致大量的纹理堆砌在两个小三角形上。
最后,将集合转换为数组并返回调用代码。
绘制赛道
有了基点和定义了方法后,添加以下代码将基点转换为一个顶点数组:
List<Vector3> extendedTrackPoints = GenerateTrackPoints(basePoints); trackVertices = GenerateTrackVertices(extendedTrackPoints);
定义了顶点后,就可以在屏幕上绘制一些三角形了:
basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = road; basicEffect.TextureEnabled = true; basicEffect.VertexColorEnabled = false; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip,trackVertices, 0, trackVertices.Length - 2); pass.End(); } basicEffect.End();
可见教程6-1学习如何绘制一个TriangleList和教程6-2学习如何绘制一个带纹理的三角形。
围栏
前面的代码生成了一个完全平坦的赛道。这是因为赛道的每个点的法线都是(0,1,0) Up方向。虽然这样做已经可以产生一个可用的赛道,但你还想让汽车保持在赛道上而不致冲出赛道。
要让赛道看起来更加真实,你需要在赛道上加上护栏防止车飞出赛道。
前面的代码使用(0,1,0) Up向量作为法线计算side向量。这次,你使用一个更加合适的法线向量。基于护栏计算法线几乎是不可能的。解决这个问题的方法是记住最后一个法线向量并对赛道上的每个点调整这个法线。
使用这个方法,你需要给法线指定一个初始值。如果你确定赛道的起点是平的,可以简单地使用(0,1,0) Up向量作为初始法线,所以在for循环中定义以下代码:
Vector3 currentNormal = Vector3.Up;
对赛道上的每个点,你需要考虑赛道的弯曲程度调整这个向量。 这个弯曲程度可以用离心方向表示:这个方向就是你将车拉回的方向,使车不至于飞出赛道外,如图5-35所示。
图5-35 找到离心(centrifugal)方向
你可以通过两个叉乘获取这个方向。首先,需要叉乘指向当前点的汽车行驶的方向(lastCarDir)和指向下一个点的汽车应该行驶的方向(carDir)。这两个方向如图5-35的左图所示。计算的结果向量垂直于这两个方向,指向纸外,所以很难在纸上表示出来。然后,叉乘这个结果向量和carDir,获取centriDir向量,这个向量总是指向弯曲赛道的内侧。
图5-35的右图显示了一个复杂的3D情况。
下面是代码:
Vector3 carDir = trackPoints[i + 1] - trackPoints[i]; carDir.Normalize(); Vector3 lastCarDir = trackPoints[i] - trackPoints[i - 1]; lastCarDir.Normalize(); Vector3 perpDir = Vector3.Cross(carDir, lastCarDir); Vector3 centriDir = Vector3.Cross(carDir, perpDir);
因为centriDir方向指向曲线的内侧,所以赛道围栏必须垂直于这个向量。
基于以上理由,需要在赛道的每个顶点的法线向量中添加这个向量。这会让side向量慢慢地垂直于centriDir向量。
但是在一个很长的过程中,这会变得太突出,所以你需要添加某个复位因子。对这个复位因子使用Up向量可以让法线在过程结束时恢复到Up向量:
currentNormal = currentNormal + centriDir * banking + Vector3.Up/banking; currentNormal.Normalize();
banking变量的值越大, 赛道添加的围栏越多。这里起你可以使用前面的代码,但别忘了用currentNormal代替(0,1,0) Up向量:
Vector3 sideDir = Vector3.Cross(currentNormal, carDir); sideDir.Normalize(); currentNormal = Vector3.Cross(carDir, sideDir);
当使用这个代码时,你的赛道就拥有了围栏。而且,这个代码还可以让赛道循环!
代码
GenerateTrackPoints方法接受基点的数组并进行了扩展,让这个方法可以添加赛道的细节:
private List<Vector3> GenerateTrackPoints(List<Vector3> basePoints) { basePoints.Add(basePoints[0]); basePoints.Add(basePoints[1]); basePoints.Add(basePoints[2]); List<Vector3> allPoints = new List<Vector3>(); for (int i = 1; i < basePoints.Count-2; i++) { List<Vector3> part = InterpolateCR(basePoints[i - 1], basePoints[i], basePoints[i + 1], basePoints[i + 2]); allPoints.AddRange(part); } return allPoints; }
基于这个扩展过的集合,GenerateTrackVertices方法创建了一个可以绘制带有围栏的赛道顶点数组:
private VertexPositionNormalTexture[] GenerateTrackVertices(List<Vector3>trackPoints) { float halfTrackWidth = 0.2f; float textureLength = 0.5f; float banking = 2.0f; float distance = 0; List<VertexPositionNormalTexture> verticesList = new List<VertexPositionNormalTexture>(); Vector3 currentNormal = Vector3.Up; for (int i = 1; i < trackPoints.Count-1; i++) { Vector3 carDir = trackPoints[i + 1] - trackPoints[i]; carDir.Normalize(); Vector3 lastCarDir = trackPoints[i] - trackPoints[i - 1]; lastCarDir.Normalize(); Vector3 perpDir =Vector3.Cross(carDir, lastCarDir); Vector3 centriDir = Vector3.Cross(carDir,perpDir); currentNormal = currentNormal + Vector3.Up/banking + centriDir * banking; currentNormal.Normalize(); Vector3 sideDir = Vector3.Cross(currentNormal, carDir); sideDir.Normalize(); currentNormal = Vector3.Cross(carDir, sideDir); Vector3 outerPoint = trackPoints[i] + sideDir * halfTrackWidth; Vector3 innerPoint = trackPoints[i] - sideDir * halfTrackWidth; distance += carDir.Length(); VertexPositionNormalTexture vertex; vertex = new VertexPositionNormalTexture(innerPoint, currentNormal, new Vector2(0, distance / textureLength)); verticesList.Add(vertex); vertex = new VertexPositionNormalTexture(outerPoint, currentNormal, new Vector2(1, distance / textureLength)); verticesList.Add(vertex); } VertexPositionNormalTexture extraVert = verticesList[0]; extraVert.TextureCoordinate.Y = distance / textureLength; verticesList.Add(extraVert); extraVert = verticesList[1]; extraVert.TextureCoordinate.Y = distance / textureLength; verticesList.Add(extraVert); return verticesList.ToArray(); }
有了这两个方法,可以很容易地从一个3D点的集合绘制一条赛道:
List<Vector3> basePoints = new List<Vector3>(); basePoints.Add(new Vector3(2, 0, 4)); basePoints.Add(new Vector3(0, 0, 0)); basePoints.Add(new Vector3(-2, 0, -4)); basePoints.Add(new Vector3(2, 0, -4)); basePoints.Add(new Vector3(0, 1, 0)); basePoints.Add(new Vector3(-2, 0, 4)); List<Vector3> extendedTrackPoints = GenerateTrackPoints(basePoints); trackVertices = GenerateTrackVertices(extendedTrackPoints);