§12.3赛道

除了有HUD这个游戏并没有真正看起来像一个赛车游戏,在发光和颜色修正post-screen shaders作用下更像是一个幻想角色扮演,没有赛道和赛车使它看起来不像一个赛车游戏。单单把车放在场景中看起来挺有趣,但你不想在地面上驾驶,尤其是场景看起来不那么好(1场景纹纹理像素2×2米,使整个车放置在2个纹理像素上)。

这个游戏的构思是制作一些像赛道狂飙游戏类似的赛道,但经过研究赛道狂飙和游戏中的编辑器后,你会看到游戏中的绘制过程是如此的复杂。我把赛道作为一个整体渲染,而赛道狂飙的关卡是由许多不同的赛道块构成的,相互间配合完美,通过这种方式,你可以把三个轨道环相互连接而不需要自己绘制或创造它。这种方法的缺点是,可用的赛道块有限,从开发者的角度来看,创造数以百计的这些赛道块要做大量的工作,更别说要在所有关卡中测试它们。

因此,这一构思被放弃。我回到我原来的做法,只是创造一个简单的二维赛道并通过场景高程图增加了一点高度。我想使用一张绘制有赛道的位图,在位图上以一条白线作为赛道,然后从位图上提取位置信息和创建三维顶点并将其导入到游戏中。但尝试后发现这个方法使赛道紧贴在场景地面上,要实现轨道环,坡道,疯狂的曲线是不可能的,或者至少很难实现。

因此,这个构思再次被放弃。为了更好地了解赛道看起来如何我使用了3D Studio Max,通过spline函数建立一个仅有4个点的简单循环轨道(见图12 -12)。旋转90度到左侧,这看起来很像赛道,比位图的方法更吸引人。

图12-12

我得把这些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)

图12-13

借助于红色的向上向量和绿色的切线向量,这条赛道看起来有点像公路了。你现在要做的是调整赛道生成代码,测试更多的放样线条。在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()

图12-14

插值样条

你可能会问如何只通过在4个或8个输入点获得所有这些点和这些点如何才能插值得更好。这一切都发生在TrackLine的构造函数中,或更准确地说,应该是在Load方法中,它可以让你在需要重新生成时重新载入数据。第一次看到Load方法时,你会觉得不很容易,它是加载所有赛道数据、验证赛道数据、插值和生成向上和切线向量的主要方法。隧道和场景物体也在这里生成。

Load方法做了以下事情:

当我开始编写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。也许将来我会为这个游戏制作一个赛道编辑器,至少能让你在游戏中直接创建简单的赛道。

图12-15

我最初认为导出这种数据并不容易。.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文件。

图12-16

ColladaLoader类只是一个很短的类,它加载Collada文件(只是一个xml文件),让使用XmlHelper方法的派生类更容易。

想对加载过程了解得更多,可以使用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)也很重要,但你不会经常看到它们。

图12-17

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)。

图12-18

你可能会问,为什么是四个部分产生每个道路片,原因不是因为我喜欢让低档的GPU处理很多多边形。这项技术是用来改善赛道的视觉效果的,特别是在曲线的情况下。

图12-19能更好地解释这个问题。如你所见,构成不平行的方块的两个多边形并不总是大小相同的,但它们仍然使用同样数量的纹理像素。在右边你可以看到一个极端的情况下,道路的右下角部分严重扭曲,不看好了。

图12-19

这个问题可通过将道路分成多个部分加以解决。你可以将道路片分成四个部分,这样做道路看起来好多了。

最后结果

让场景和道路正确显示要做大量的工作,但现在你已做得不错了,至少图形部分不错。你可以在Track命名空间下的类中看到许多小窍门和技巧。请查看单元测试以了解更多关于如何绘制道路的两旁、圆轨道和隧道的知识。图12-20显示了Track类中的TestRenderTrack单元测试的最终结果。

图12-20

和本章第一部分的场景渲染整合在一起,你就有了一个相当不错渲染引擎。加上背景的post-screen shader天空盒,场景和道路渲染看起来相当不错(见图12-21)。Post-screen glow shader也使一切都配合得更好,尤其是在场景中有很多物体的情况下。

图12-21


发布时间:2008/10/28 8:22:39  阅读次数:8510

2006 - 2024,推荐分辨率1024*768以上,推荐浏览器Chrome、Edge等现代浏览器,截止2021年12月5日的访问次数:1872万9823 站长邮箱

沪ICP备18037240号-1

沪公网安备 31011002002865号