§12.3赛道
除了有HUD这个游戏并没有真正看起来像一个赛车游戏,在发光和颜色修正post-screen shaders作用下更像是一个幻想角色扮演,没有赛道和赛车使它看起来不像一个赛车游戏。单单把车放在场景中看起来挺有趣,但你不想在地面上驾驶,尤其是场景看起来不那么好(1场景纹纹理像素2×2米,使整个车放置在2个纹理像素上)。
这个游戏的构思是制作一些像赛道狂飙游戏类似的赛道,但经过研究赛道狂飙和游戏中的编辑器后,你会看到游戏中的绘制过程是如此的复杂。我把赛道作为一个整体渲染,而赛道狂飙的关卡是由许多不同的赛道块构成的,相互间配合完美,通过这种方式,你可以把三个轨道环相互连接而不需要自己绘制或创造它。这种方法的缺点是,可用的赛道块有限,从开发者的角度来看,创造数以百计的这些赛道块要做大量的工作,更别说要在所有关卡中测试它们。
因此,这一构思被放弃。我回到我原来的做法,只是创造一个简单的二维赛道并通过场景高程图增加了一点高度。我想使用一张绘制有赛道的位图,在位图上以一条白线作为赛道,然后从位图上提取位置信息和创建三维顶点并将其导入到游戏中。但尝试后发现这个方法使赛道紧贴在场景地面上,要实现轨道环,坡道,疯狂的曲线是不可能的,或者至少很难实现。
因此,这个构思再次被放弃。为了更好地了解赛道看起来如何我使用了3D Studio Max,通过spline函数建立一个仅有4个点的简单循环轨道(见图12 -12)。旋转90度到左侧,这看起来很像赛道,比位图的方法更吸引人。
我得把这些spline数据从3D Studio Max中导出并插到我的引擎中,这样我就可以在3D Studio Max中创建赛道并将它导入到赛车游戏引擎中。困难的部分是从spline数据中产生一条可用的赛道,因为每个spline点只是一个点,而不是一片有方向有宽度的道路。在花更多的时间试图找出最好的方式来产生赛道,将spline数据导入到你的游戏前,你应该确保这一想法可行。
单元测试
这个游戏再次进行了一些繁重的单元测试。由新的TrackLine类中的第一个叫做TestRenderingTrack的单元测试开始,它只是创建了一个简单的如3D Studio Max中类似的曲线,并把它显示在屏幕上:
public static void TestRenderingTrack()
{
TrackLine testTrack = new TrackLine( new Vector3[] { new Vector3(20, -20, 0), new Vector3(20, 20, 0), new Vector3(-20, 20, 0), new Vector3(-20, -20, 0),
}
);
TestGame.Start(
delegate
{
ShowGroundGrid();
ShowTrackLines(testTrack);
ShowUpVectors(testTrack);
}
);
} // TestRenderingTrack()
ShowGroundGrid方法只是在xy平面上显示一些网格线以帮助你知道地面在哪。之后我在model类中写了这个方法,能够被重复使用。ShowTrackLines是最重要的方法,因为它显示了所有的线条和插值点,这些线条和插值点已在TrackLine类的构造函数中生成。最后ShowUpVectors方法告诉向量的向上方向供赛道每个点使用。没有向上向量,你将无法正确地生成道路的左右两侧。例如,在曲线赛道上应该倾斜、在循环赛道上你需要向上向量指向圆轨道圆心,而不只是向上。
ShowTrackLines辅助方法显示赛道的每个点,它们之间通过白色直线连接。当你执行TestRenderingTrack单元测试后就可以看到如图12-13的画面。
public static void ShowTrackLines(TrackLine track)
{
// Draw the line for each line part
for (int num = 0; num < track.points.Count; num++)
BaseGame.DrawLine( track.points[num].pos, track.points[(num + 1)%track.points.Count].pos, Color.White);
} // ShowTrackLines(track)
借助于红色的向上向量和绿色的切线向量,这条赛道看起来有点像公路了。你现在要做的是调整赛道生成代码,测试更多的放样线条。在TrackLine类中你可以看到我的几个测试赛道,这些赛道是通过手工添加一些3D点创建的,更多的赛道可通过使用Collada文件实现,这时要将3D Studio Max中的赛道数据导入到你的引擎,这在接下去会讨论到。
在你查看构造函数中的放样线条插值代码前,你也可以创建一个简单的循环赛道,只需转换赛道顶点的x和z值(见图12-14 )。为了使样条看起来更圆我还补充四个新的点。新的TestRenderingTrack单元测试如下所示:
public static void TestRenderingTrack()
{
TrackLine testTrack = new TrackLine( new Vector3[] { new Vector3(0, 0, 0), new Vector3(0, 7, 3), new Vector3(0, 10, 10), new Vector3(0, 7, 17), new Vector3(0, 0, 20), new Vector3(0, -7, 17), new Vector3(0, -10, 10), new Vector3(0, -7, 3),
}
); // [Rest stays the same]
} // TestRenderingTrack()
插值样条
你可能会问如何只通过在4个或8个输入点获得所有这些点和这些点如何才能插值得更好。这一切都发生在TrackLine的构造函数中,或更准确地说,应该是在Load方法中,它可以让你在需要重新生成时重新载入数据。第一次看到Load方法时,你会觉得不很容易,它是加载所有赛道数据、验证赛道数据、插值和生成向上和切线向量的主要方法。隧道和场景物体也在这里生成。
Load方法做了以下事情:
- 它允许重新载入,这对载入和重新开始关卡是非常重要的。如果你再次调用Load方法,以往的数据会自动被清除。 验证所有数据以确保你可以产生赛道并使用所有辅助类。
- 检查赛道上的每一点看看它是否在场景之上。如果没有,该点会被纠正,而且周围的点也会稍微上升少许以使赛道看起来更光滑。通过这种方式,你可以轻松地在Max中生成一个三维赛道,而当将赛道放置在场景之上时,就无需担心场景的实际高度。
- 圆轨道被简化为上下两个取样点。加载代码会自动检测这两个点并用完整循环的九个点取代它们,这样插入更多的点以产生非常光滑和正确的圆轨道。然后,所有赛道上的点通过Catmull-Rom插值方法被插值。你马上就会看到这种方法。
- 向上和切线向量会生成并插值好几次以使道路尽可能平滑。切线向量尤其不应该突然改变方向或翻转到另一边,这将使得在这条道路上开车变得非常困难。这个代码我花费了最长的时间才使之能工作正常。
- 然后,所有分析所有辅助类和对应赛道上的每一个点的道路宽度被储存,以便接下去使用,实际渲染发生在Track类,它是基于TrackLine类的。 道路纹理的纹理坐标也在这里生成,因为你将所有赛道点的信息存储在TrackVertex数组中,这样可以使接下去的渲染更容易。只有u纹理坐标是储存在这里的,而v纹理坐标在后来只是被设置为0或1,这取决于你是在道路的左边还是右边。
- 然后,分析隧道辅助类并生成隧道数据。这里的代码只是构建了一些新的点供以后使用。它们被用来在Track类中绘制带有隧道纹理的隧道盒。
- 最后所有的场景模型被添加。他们和赛道数据一起被保存为一个完整的关卡。附加的场景物体也在Track类中自动生成,例如,路边的路灯等。
当我开始编写TrackLine类时,构造函数只能通过Catmull-rom spline辅助方法从输入点中生成新的插值点。该代码看上去如下,在Load方法中也能找到类似代码:
// Generate all points with help of catmull rom splines
for (int num = 0; num < inputPoints.Length; num++)
{
// Get the 4 required points for the catmull rom spline
Vector3 p1 = inputPoints[num-1 < 0 ? inputPoints.Length-1 : num-1];
Vector3 p2 = inputPoints[num];
Vector3 p3 = inputPoints[(num + 1) % inputPoints.Length];
Vector3 p4 = inputPoints[(num + 2) % inputPoints.Length];
// Calculate number of iterations we use here based
// on the distance of the 2 points we generate new points from. float distance = Vector3.Distance(p2, p3);
int numberOfIterations = (int)(NumberOfIterationsPer100Meters * (distance / 100.0f));
if (numberOfIterations <= 0)
numberOfIterations = 1;
Vector3 lastPos = p1;
for (int iter = 0; iter < numberOfIterations; iter++)
{
TrackVertex newVertex = new TrackVertex( Vector3.CatmullRom(p1, p2, p3, p4, iter / (float)numberOfIterations));
points.Add(newVertex);
} // for (iter)
} // for (num)
更复杂的赛道
单元测试已经能让一切都启动和运行了,但赛道越复杂,通过输入每个样点的3D位置产生赛道就更难。要让事情变得简单点,可以使用从3D Max Studio中导出的spline,这可以让创建和修改spline变得更容易。
看看如图12-15所示的XNA Racing游戏中专家关卡的赛道。这条赛道仅包含约85个点,插值到2000个点使赛道约有24000个多边形。赛道围栏和额外的赛道物体在以后生成。构建这样的一条赛道并调整它,如果没有一个好的编辑器几乎是不可能的,不过幸好有3D Max。也许将来我会为这个游戏制作一个赛道编辑器,至少能让你在游戏中直接创建简单的赛道。
我最初认为导出这种数据并不容易。.x档案不支持spline,.fbx文件也不行。即使他们能导出spline,你仍需要做大量的工作从赛道中提取数据,因为在XNA中从导入的模型中获得顶点数据是不可能的。我决定使用目前非常流行的Collada格式,这种格式允许在不同的应用程序间互相导入导出3D数据。相比其他格式,Collada的主要优势是一切都存储为XML格式,从导出的文件上你可以很容易看出哪个数据对应哪个功能。你甚至不需要寻找任何文件,只需寻找你需要的数据并提取它(在这里,你只需寻找spline和辅助数据,其余的对你并不重要)。
对游戏来说Collada不是一个真正优秀的导出格式,因为它通常储存了太多的信息,而且XML数据仅仅是一堆文字,所以比起二进制文件,Collada文件的尺寸也大得多,出于这个理由,而且我也不能在XNA Starter Kit中使用任何外部数据格式,所有的Collada数据在TrackImporter类中被转换为内部数据格式。使用自己的数据格式加快了加载过程,并确保没有人能创建自己的赛道。嘿,等一下,你不希望别人创建自己的赛道吗?我的确希望这变得更容易,你需要3D Studio Max才能创建或改建赛道并不好。我必须在以后实现某种方法可以导入和创建赛道。
导入赛道数据
为了使装载Collada文件变得容易些使用了一些辅助类。首先,XmlHelper类(见图12-16 )能帮你加载和管理XML文件。
ColladaLoader类只是一个很短的类,它加载Collada文件(只是一个xml文件),让使用XmlHelper方法的派生类更容易。
- ColladaTrack是用来加载赛道本身(trackPoints),其他辅助对象如widthHelpers可使赛道变宽和变窄,roadHelpers用于隧道,棕榈树,路灯等路边的物体。最后所有的场景物体在你接近他们时被显示(因为在场景中有大量的物体)。
- ColladaCombiModels是一个小的辅助类,它用于一次加载并显示多个模型,只需设置一个包含多达10个模型的组合模型,这十个模型有不同的位置和旋转值。例如,如果你想放置一个具有建筑物的城市区域,只需使用Buildings.CombiModel文件,如果你需要一些棕榈树外加几块石头可使用Palms.CombiModel文件。
想对加载过程了解得更多,可以使用TrackLine和Track类中的单元测试,但更重要的查看ColladaTrack构造函数本身:
public ColladaTrack(string setFilename) : base(setFilename)
{
// Get spline first (only use one line)
XmlNode geometry = XmlHelper.GetChildNode(colladaFile, "geometry");
XmlNode visualScene = XmlHelper.GetChildNode(colladaFile, "visual_scene");
string splineId = XmlHelper.GetXmlAttribute(geometry, "id");
// Make sure this is a spline, everything else is not supported.
if (splineId.EndsWith("-spline") == false)
throw new Exception("The ColladaTrack file " + Filename + " does not have a spline geometry in it. Unable to load " + "track!");
// Get spline points XmlNode pointsArray = XmlHelper.GetChildNode(geometry, "float_array");
// Convert the points to a float array float[] pointsValues = StringHelper.ConvertStringToFloatArray( pointsArray.FirstChild.Value);
// Skip first and third of each input point (MAX tangent data)
trackPoints.Clear();
int pointNum = 0;
while (pointNum < pointsValues.Length) {
// Skip first point (first 3 floating point values) pointNum += 3;
// Take second vector trackPoints.Add(MaxScalingFactor * new Vector3( pointsValues[pointNum++], pointsValues[pointNum++], pointsValues[pointNum++]));
// And skip thrid
pointNum += 3;
}
// while (pointNum)
// Check if we can find translation or scaling values for our
// spline XmlNode
splineInstance = XmlHelper.GetChildNode( visualScene, "url", "#" + splineId);
XmlNode splineMatrixNode = XmlHelper.GetChildNode( splineInstance.ParentNode, "matrix");
if (splineMatrixNode != null)
throw new Exception("The ColladaTrack file " + Filename + " should not use baked matrices. Please export again " + "without baking matrices. Unable to load track!");
XmlNode splineTranslateNode = XmlHelper.GetChildNode( splineInstance.ParentNode, "translate");
XmlNode splineScaleNode = XmlHelper.GetChildNode( splineInstance.ParentNode, "scale");
Vector3 splineTranslate = Vector3.Zero;
if (splineTranslateNode != null)
{
float[] translateValues = StringHelper.ConvertStringToFloatArray( splineTranslateNode.FirstChild.Value);
splineTranslate = MaxScalingFactor * new Vector3( translateValues[0], translateValues[1], translateValues[2]);
}
// if (splineTranslateNode)
Vector3 splineScale = new Vector3(1, 1, 1);
if (splineScaleNode != null)
{
float[] scaleValues = StringHelper.ConvertStringToFloatArray( splineScaleNode.FirstChild.Value);
splineScale = new Vector3( scaleValues[0], scaleValues[1], scaleValues[2]);
}
// if (splineTranslateNode)
// Convert all points with our translation and scaling values
for (int num = 0; num < trackPoints.Count; num++)
{
trackPoints[num] = Vector3.Transform(trackPoints[num], Matrix.CreateScale(splineScale) * Matrix.CreateTranslation(splineTranslate));
}
// for (num)
// [Now Helpers are loaded here, the loading code is similar]
} // ColladaTrack(setFilename)
获取spline数据本身并不是很难,但获取移动,缩放,旋转值要多费些功夫(辅助类也更复杂),但在你编写和测试了此代码后(有几个单元测试和测试文件被用来实现这一构造函数),创建新的赛道并将它们导入到游戏中是很容易的。
从赛道数据生成顶点
获取赛道数据和导入辅助数据只完成了一半工作。你已经看到TrackLine类的构造函数是多么复杂了,它帮你产生插值点,并建立向上和切线向量。纹理坐标和所有辅助和场景模型也在这里处理。但是你现在仍然只有一大堆点,并没有一条真正的道路让你的车可以行使其上。为绘制一条具有纹理的真正的道路(见图12-17),你需要首先为所有的三维数据创建顶点,并最终生成道路,还要包括其他动态创建的对象,如护栏。最重要的纹理是道路本身,但没有法线贴图游戏看起来有点枯燥。法线贴图给道路添加了一个闪闪发光的结构,使道路在面向太阳时有光泽。道路两旁的纹理、背景(RoadBack.dds)和隧道(RoadTunnel.dds)也很重要,但你不会经常看到它们。
TrackLine类处理所有这些纹理,有道路材质,道路水泥柱、护栏、检查站等,它是基于,它从Track类继承而来的。
Landscape类用来绘制赛道和所有场景物体以及场景本身,最后才能使汽车在道路上行驶。你还需要物理学处理在赛道上的运动、与护栏的碰撞,这在下一章会说到。
Track类负责所有道路材质,生成所有顶点以及索引缓冲,并最终在shader的帮助下渲染所有的赛道顶点。大多数材质使用NormalMapping中的Specular20技术产生一个有光泽的道路,但对隧道和其他非光泽道路材质,应使用Diffuse20技术。
绘制赛道的单元测试很简单,所有你想做的事就是绘制赛道。
public static void TestRenderTrack()
{
Track track = null;
TestGame.Start( delegate
{ track = new Track("TrackBeginner", null);
},
delegate
{
ShowUpVectors(track); track.Render();
}
);
} // TestRenderingTrack()
如你所见你仍然使用TrackLine类中的ShowUpVectors辅助方法,因为你是从Track类中继承而来的。Render方法也类似于前一章Mission类中的场景渲染的方法。
public void Render()
{
// We use tangent vertices for everything here
BaseGame.Device.VertexDeclaration = TangentVertex.VertexDeclaration;
// Restore the world matrix
BaseGame.WorldMatrix = Matrix.Identity;
// Render the road itself
ShaderEffect.normalMapping.Render( roadMaterial, "Specular20",
delegate {
BaseGame.Device.Vertices[0].SetSource(roadVb, 0, TangentVertex.SizeInBytes);
BaseGame.Device.Indices = roadIb;
BaseGame.Device.DrawIndexedPrimitives( PrimitiveType.TriangleList, 0, 0, points.Count * 5, 0, (points.Count - 1) * 8);
}
); // [etc. Render rest of road materials]
} // Render()
嗯,看来并不十分复杂。看一下生成的道路顶点和索引缓冲的代码。私有辅助类GenerateVerticesAndObjects执行上述操作:
private void GenerateVerticesAndObjects(Landscape landscape)
{
#region Generate the road vertices
// Each road segment gets 5 points:
// left, left middle, middle, right middle, right.
// The reason for this is that we would have bad triangle errors if the
// road gets wider and wider. This happens because we need to render
// quads, but we can only render triangles, which often have different
// orientations, which makes the road very bumpy. This still happens
// with 8 polygons instead of 2, but it is much better this way.
// Another trick is to not do so many iterations in TrackLine, which
// causes this problem. Better to have a not so round track, but at
// least the road up/down itself is smooth.
// The last point is duplicated (see TrackLine) because we have 2 sets
// of texture coordinates for it (begin block, end block).
// So for the index buffer we only use points.Count-1
blocks. roadVertices = new TangentVertex[points.Count * 5];
// Current texture coordinate for the roadway (in direction of
// movement)
for (int num = 0; num < points.Count; num++)
{ // Get vertices with the help of the properties in the TrackVertex
// class. For the road itself we only need vertices for the left
// and right side, which are vertex number 0 and 1.
roadVertices[num * 5 + 0] = points[num].RightTangentVertex;
roadVertices[num * 5 + 1] = points[num].MiddleRightTangentVertex;
roadVertices[num * 5 + 2] = points[num].MiddleTangentVertex;
roadVertices[num * 5 + 3] = points[num].MiddleLeftTangentVertex;
roadVertices[num * 5 + 4] = points[num].LeftTangentVertex;
} // for (num)
roadVb = new VertexBuffer( BaseGame.Device, typeof(TangentVertex), roadVertices.Length, ResourceUsage.WriteOnly, ResourceManagementMode.Automatic);
roadVb.SetData(roadVertices);
// Also calculate all indices, we have 8 polygons for each segment
// with 3 vertices each. We have 1 segment less than points because
// the last point is duplicated (different tex coords).
int[] indices = new int[(points.Count - 1) * 8 * 3];
int vertexIndex = 0;
for (int num = 0; num < points.Count - 1; num++)
{ // We only use 3 vertices (and the next 3 vertices),
// but we have to construct all 24 indices for our 4 polygons.
for (int sideNum = 0; sideNum < 4; sideNum++)
{
// Each side needs 2 polygons.
// 1. Polygon
indices[num * 24 + 6 * sideNum + 0] = vertexIndex + sideNum;
indices[num * 24 + 6 * sideNum + 1] = vertexIndex + 5 + 1 + sideNum;
indices[num * 24 + 6 * sideNum + 2] = vertexIndex + 5 + sideNum;
// 2. Polygon
indices[num * 24 + 6 * sideNum + 3] = vertexIndex + 5 + 1 + sideNum;
indices[num * 24 + 6 * sideNum + 4] = vertexIndex + sideNum;
indices[num * 24 + 6 * sideNum + 5] = vertexIndex + 1 + sideNum;
} // for (num)
// Go to the next 5 vertices
vertexIndex += 5;
} // for (num)
// Set road back index buffer
roadIb = new IndexBuffer( BaseGame.Device, typeof(int), indices.Length, ResourceUsage.WriteOnly, ResourceManagementMode.Automatic);
roadIb.SetData(indices);
#endregion
// [Then the rest of the road back, tunnel, etc. vertices are
// generated here and all the landscape objects, checkpoints, palms,
// etc. are generated at the end of this method]
} // GenerateVerticesAndObjects(landscape)
在编写这个代码时我写了很多注释。第一部分生成一个很大的切线数组,数组大小是TrackLine类中的赛道顶点的5倍。此数据直接传递到顶点缓冲区,然后被用于构造多边形的索引缓冲区。每个道路片有8个多边形(由四部分组成,每部分两个多边形),因此该索引缓冲区大小是赛道顶点索引的24倍。为了确保仍然能够正确使用所有这些索引,必须使用int类型替代short类型,以前我使用short类型是因为这样做能节省一半内存。但在这种情况下有超过32000个索引( 专家关卡的赛道有2000个道路片,它的24倍已达到48000个索引)。因为赛道是自动生成而不是手工产生,所以你需要许多迭代点,如果你没有足够的迭代点会导致重叠错误,这样就没法使赛道足够圆滑(见图12-18)。
你可能会问,为什么是四个部分产生每个道路片,原因不是因为我喜欢让低档的GPU处理很多多边形。这项技术是用来改善赛道的视觉效果的,特别是在曲线的情况下。
图12-19能更好地解释这个问题。如你所见,构成不平行的方块的两个多边形并不总是大小相同的,但它们仍然使用同样数量的纹理像素。在右边你可以看到一个极端的情况下,道路的右下角部分严重扭曲,不看好了。
这个问题可通过将道路分成多个部分加以解决。你可以将道路片分成四个部分,这样做道路看起来好多了。
最后结果
让场景和道路正确显示要做大量的工作,但现在你已做得不错了,至少图形部分不错。你可以在Track命名空间下的类中看到许多小窍门和技巧。请查看单元测试以了解更多关于如何绘制道路的两旁、圆轨道和隧道的知识。图12-20显示了Track类中的TestRenderTrack单元测试的最终结果。
和本章第一部分的场景渲染整合在一起,你就有了一个相当不错渲染引擎。加上背景的post-screen shader天空盒,场景和道路渲染看起来相当不错(见图12-21)。Post-screen glow shader也使一切都配合得更好,尤其是在场景中有很多物体的情况下。
发布时间:2008/10/28 上午8:22:39 阅读次数:9188