创建场景和赛道——赛道

赛道

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

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

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

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

1
图 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)

2
图 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()

3
图 12-14

插值样条

你可能会问如何只通过在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。也许将来我会为这个游戏制作一个赛道编辑器,至少能让你在游戏中直接创建简单的赛道。

4
图 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文件。

5
图 12-16

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)也很重要,但你不会经常看到它们。

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

7
图 12-18

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

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

8
图 12-19

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

最后结果

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

图12-20显示了Track类中的TestRenderTrack单元测试的最终结果。

9
图 12-20

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

10
图 12-21

原文地址:https://www.cnblogs.com/AlexCheng/p/2120199.html