赞助广告

 

年份

最新评论

评论 RSS

在3D世界中创建不同的相机模式——创建一个相机飞入效果

clock 十月 23, 2010 09:26 by author alex
2.7 创建一个相机飞入效果 问题 你想平滑地将相机从一个位置移动到另一个位置,当然你也让相机的观察目标也能平滑地移动。 具体的的说,你想要一个功能让相机能沿着一条平滑的曲线从开始位置到结束位置。这个移动过程应该是平滑的,在过程结束后,你想让相机回到常规状态,将它作为一个第一人称相机。 解决方案 这个过程需要一个时间变量,当相机在开始位置时这个变量为0,到达结束位置时为1。 通过指定第三个位置作为相机移动的中点,你可以定义一个贝塞尔曲线(Bezier curve)。贝塞尔曲线是一条通过起点和终点的光滑曲线,非常靠近起点和终点之间指定的额外点(例如第三个点)。通过给一个贝塞尔曲线指定一个介于0和1之间的时间变量的当前值,它会返回对应时间的曲线上的位置。 观察目标的位置会在目标起点和终点间进行线性插值。结果是,相机会沿着起点指向终点的光滑曲线移动,同时观察目标也会沿着目标起点和中点间的直线运动。 要使用第一人称相机的代码,你不能简单地只改变相机的位置和观察目标,因为相机是基于目标位置和绕UP和Right轴的旋转来计算观察目标的。所以你需要调整相机的旋转值。 一切准备就绪后,这个过程的平滑初末位置就可以通过使用MathHelper. SmoothStep 方法获取。 注意:你可以使用包含在XNA框架中的Curve类编写复杂相机路径的脚本,但它还不够完善用以处理相机的飞入,因为它缺少了一些细小的功能。例如,这个类不支持3D点,需要你手动设置Curve类的关键点。总之,本教程可以用Curve功能替换的部分就是Bezier方法,如果你想使用XNA框架的Curve功能代替Bezier方法,本教程90%的部分还是有用的。 工作原理 对贝塞尔相机的飞入效果,你需要保存这些变量: float bezTime = 1.0f; Vector3 bezStartPosition; Vector3 bezMidPosition; Vector3 bezEndPosition; Vector3 bezStartTarget; Vector3 bezEndTarget; 变量bezTime保存飞入过程。当开始一个飞入行为时,将这个变量设为0。当运行时,在更新过程中将它增加到1,这个值超过1表示过程结束。 在飞入过程中,你需要保存一些位置,诸如起点,终点和观察目标,但你还要计算中点获取一个光滑曲线。这些变量都应该在飞入开始时指定。 在下面的代码中,你可以找到触发飞入行为的方法,这个方法需要相机的初始位置和最终位置、观察目标的初始位置和结束位置: private void InitBezier(Vector3 startPosition, Vector3 startTarget, Vector3 endPosition, Vector3 endTarget) { bezStartPosition = startPosition; bezEndPosition = endPosition; bezMidPosition = (bezStartPosition + bezEndPosition) / 2.0f; Vector3 midShiftDirecton = new Vector3(1, 1, 0)*2; Vector3 cameraDirection = endPosition - startPosition; Vector3 perpDirection = Vector3.Cross(upV, cameraDirection); perpDirection.Normalize(); Vector3 midShiftDirecton = new Vector3(0, 1, 0) + perpDirection; bezMidPosition += cameraDirection.Length() * midShiftDirecton; bezStartTarget = startTarget; bezEndTarget = endTarget; bezTime = 0.0f; } startPosition, endPosition, startTarget和endTarget参数可以立即被存储到对应的变量中。 midPosition是插入到开始的和结束点之间的额外点,它需要被计算。你可以通过首先计算起始点和终点的中点,然后取两者的平均值做到。要从曲线偏离一点距离,你需要通过添加一个垂直于这条线上的方向将这个点偏离相机的起点和终点之间的直线。 你可以通过叉乘这两个方法获取垂直于这两个方向的方向。在本教程中,你想让这个方向垂直于指向相机的方向和Up向量。首先计算指向相机的方向,这和获取其他方向的做法一样:通过获取最终位置并减去初始位置。然后叉乘这个方向和Up方向,就可以获取垂直于这两个方向的方向。 如果你将中点移动到这个方向,如果起点和终点靠得很近时就会获得一个漂亮的曲线。但是如果两点间的距离较大,曲线会非常平。你可以通过通过起点和终点间的距离乘以偏移量解决这个问题,这个距离可以通过找到起点和终点间的矢量长度获取。 最后将bezTime变量设置为0,表示开始飞入行为。 初始化飞入之后,你就可以调用UpdateBezier方法在每帧进行更新: private void UpdateBezier() { bezTime += 0.01f; if (bezTime > 1.0f) return; Vector3 newCamPos = Bezier(bezStartPosition, bezMidPosition, bezEndPosition, bezTime); Vector3 newCamTarget = Vector3.Lerp(bezStartTarget, bezEndTarget, bezTime); float updownRot; float leftrightRot; AnglesFromDirection(newCamTarget - newCamPos, out updownRot, out leftrightRot); fpsCam.UpDownRot = updownRot; fpsCam.LeftRightRot = leftrightRot; fpsCam.Position = newCamPos; } 这个方法首先将bezTime增加一点点。如果这个值大于1,则过程结束返回return。 否则,执行下一行代码。首先,相机的下一个位置通过传递bezTime变量从贝塞尔曲线获取,这个变量表示飞入行为的进度。然后,通过在bezStartTarget和bezEndTarget之间进行插值获取当前目标,可以在教程5-9中学习更多插值的知识。 如果你想和第一人称相机的代码组合在一起,需要计算最终的updownRot和 leftrightRot值,这可以通过调用AnglesFromDirection方法做到。这个方法有三个参数:相机的朝向(如果从目标位置减去相机位置,你就可以获取观察方向)和两个角度。通过作为“out”参数传递这两个角度,这个方法可以改变这两个值,这两个值会被存储到对应的变量中。最后,更新相机的新位置和旋转。 贝塞尔曲线 第一行代码调用Bezier方法,这个方法基于bezTime变量返回曲线上的位置,bezTime变量通常介于0和1之间。这个函数需要三个点定义一条曲线,你已经在InitBezier方法中计算过这些变量了。 对于一条由三个点定义的贝塞尔曲线,你可以使用下列函数计算任意给定时间时的位置: P(t)=Pstart*(1-t)2+2*Pmid* (1-t)*t+Pfinal*t2 看起来很难,实际上不是。让我们看一下开始t=0时的结果,使用函数 P(0)=Pstart*12+2*Pmid*1*0+Pfinal*02,结果是Pstart!在过程结束时,t=1,使用函数P(1)=Pstart*02+2*Pmid*0*1+Pfinal*12,结果是Pfinal。对所以介于0和1之间的值,结果介于开始位置和结束位置之间,并将这个点稍微拉向中点。在本教程的情况中,t就是bezTime。 下面的代码计算前面的公式: private Vector3 Bezier(Vector3 startPoint, Vector3 midPoint, Vector3 endPoint, float time) { float invTime = 1.0f - time; float timePow = (float)Math.Pow(time, 2); float invTimePow = (float)Math.Pow(invTime, 2); Vector3 result = startPoint * invTimePow; result += 2 * midPoint * time * invTime; result += endPoint * timePow; return result; } invTime就是公式中的(1-t),所以invTimePow就是(1-t)2。结果变量就是函数的最终输出结果,这个位置会返回到调用代码。 获取旋转 当你看一下UpdateBezier方法的最后一行代码时,你会发现相机的下一个位置使用了 Bezier 方法进行了计算,而下一个目标使用了简单的插值。 这用于最简单的飞入行为,因为你可以立即从这个位置和观察目标创建一个View矩阵(见教程2-1)。但是当飞入结束后,你想获取相机的运动情况。相机的位置会自动存储,但旋转变量不会。所以你需要获取刚才计算的相机位置和观察目标位置,找到对应的 leftrightRotation和updownRotation,存储这两个变量,这样就可以用于第一人称相机了。而且,第一人称相机还可以获取飞入结束后的相机的信息,不会出现错误。 这就是在AnglesFromDirection方法中进行的操作。这个方法以相机的朝向为参数,计算updownRot和leftrightRot的值。这两个值都作为这个方法的“out”参数,这意味着它们可以返回到调用代码,所以这个方法做出的改变会存储在调用方法的变量中。 private void AnglesFromDirection(Vector3 direction, out float updownAngle, out float leftrightAngle) { Vector3 floorProjection = new Vector3(direction.X, 0, direction.Z); float directionLength = floorProjection.Length(); updownAngle = (float)Math.Atan2(direction.Y, directionLength); leftrightAngle = -(float)Math.Atan2(direction.X, -direction.Z); } 教程4-17具体解释了如何对应一个方向获取旋转角度。本教程中,你必须获取两个角度,因为这个方向是在3D空间中的。首先找到leftrightRot角度。图2-5展示的是包含相机和观察目标的XZ平面。虚线是相机的朝向,线段X和Z是这个向量的X和Z分量。在直角三角形中,如果你想获取顶角,你要做的就是计算对边的反正切值,除以邻边。在本教程中,就是X除以Z。Atan2函数让你可以指定两个值而不是它们的商, 这可以避免出现两个结果。 这就是获取leftrightRot角度的方法。 图2-5 获取leftrightRot角度 要找到updownAngle,你可以使用图2-6。虚线表相机观察的方向。你想获取向上的Y方向和这个方向在XZ平面上的投影之间的角度。所以,将这两个方向传递到Atan2方法中就可以获取updownAngle。 图2-6 获取updownAngle角度 使用方法 确保在Update方法中调用UpdateBezier方法: UpdateBezier(); 现在要开始一个飞越效果,你要做的就是调用InitBezier方法! if (bezTime > 1.0f) InitBezier(new Vector3(0,10,0), new Vector3(0,0,0), new Vector3(0, 0, -20), new Vector3(0, 0, -10)); 当你想将飞越效果和相机结合起来时,你可以从相机的位置和观察目标开始: if (bezTime > 1.0f) InitBezier(fpsCam.Position, fpsCam.Position + fpsCam.Forward * 10.0f, new Vector3(0, 0, -20), new Vector3(0, 0, -10)); 你可以看到,我将开始的观察目标放置在开始位置前方10个单位,这可以给出一个平滑的结果,否则当跟随目标移动时相机会出现一个急速的移动。 平滑开始和加速 通过一个将bezTime均匀地从0增加到1,相机沿曲线的移动速度是一个常量。这会导致一个很不舒服的开始和结束,你真正想要的是让bezTime首先从0慢慢地增加到0.2,然后很快增加到0.8,在最后的0.8到1的部分又慢慢增加。这可以通过MathHelper. SmoothStep方法实现:给定一个介于0和1之间的持续增加的值,这个方法会返回一个具有光滑开始和结束的介于0和1之间的值! 这一步在UpdateBezier方法中进行,所以使用这个代码替换中间的两行代码: float smoothValue = MathHelper.SmoothStep(0, 1, bezTime); Vector3 newCamPos = Bezier(bezStartPosition, bezMidPosition, bezEndPosition, smoothValue); Vector3 newCamTarget = Vector3.Lerp(bezStartTarget, bezEndTarget, smoothValue); smoothValue变量保存这个平滑过的介于0和1之间的值,用来代替均匀增加的bezTime变量传递到方法中。 解决飞入过程结束时的不正确的相机旋转 本教程的这部分要解决在某些情况中会出现的问题。这种情况之一显示在图2-7的左图中。开始时,相机看向左方直到两个曲线相交,在这个交点上,相机会突然看向右方。 你可以通过将曲线的中点切换到左边解决这个问题,通过这种方式,你获得了如右图所示的曲线,开始时相机会看向右方而无需切换到左方。 图2-7 飞入曲线的不正确和正确的情况 你如何知道中点该移向哪个方向?参考一下图2-8的左上图和右上图,它们显示了会导致问题的两种情况。虚线显示了目标路径,实线表示相机的直线路径。 图2-8 判断如何切换中点 在这两种情况中,中点需要切换到另一个方向。这可以通过叉乘这两个方向进行检测。在这两种情况中,叉乘的结果是垂直于平面的向量,但是一种情况中是向上,另一种情况中是向下。假设向上称为upVector。接下来,如果将这个upVector与相机方向叉乘,你会获得一个垂直于相机方向和upVector的向量。最终向量的方向取决于upVector朝上还是朝下,而朝上还是朝下取决于位置和目标路径相交的情况。因为它垂直于位置路径和upVector,可以用来切换中点。 所以,在InitBezier方法中使用下列代码获取曲线正确的中点: bezMidPosition = (bezStartPosition + bezEndPosition) / 2.0f; Vector3 cameraDirection = endPosition - startPosition; Vector3 targDirection = endTarget - startTarget; Vector3 upVector = Vector3.Cross(new Vector3(targDirection.X, 0, targDirection.Z), new Vector3(cameraDirection.X, 0, cameraDirection.Z)); Vector3 perpDirection = Vector3.Cross(upVector, cameraDirection); perpDirection.Normalize(); upVector可以通过叉乘位置路径和目标路径获取,这两个路径都通过将Y分量设置为0投影到XZ平面上(通过这种方式,你获得了如图所示的直线)。通过将upVector叉乘位置路径获取垂直于upVector的方向。 当你找到这个垂直方向时,你可以将中点切换到这个方向: Vector3 midShiftDirecton = new Vector3(0, 1, 0) + perpDirection; bezMidPosition += cameraDirection.Length() * midShiftDirecton; 但是有一种情况无法正确处理,如果targDirection和cameraDirection平行,或upVector和cameraDirection平行,叉乘方法会出现问题,导致perpDirection变量为(0,0,0),当归一化这个矢量时会出错。所以,你要检测这种情况,通过设置一个任意值解决这个问题: if (perpDirection == new Vector3()) perpDirection = new Vector3(0, 1, 0); 将这个代码放在归一化perpDirection 代码行之前。 代码 下面的方法初始化变量开始一个新的飞入行为: private void InitBezier(Vector3 startPosition, Vector3 startTarget, Vector3 endPosition, Vector3 endTarget) { bezStartPosition = startPosition; bezEndPosition = endPosition; bezMidPosition = (bezStartPosition + bezEndPosition) / 2.0f; Vector3 cameraDirection = endPosition - startPosition; Vector3 targDirection = endTarget - startTarget; Vector3 upVector = Vector3.Cross(new Vector3(targDirection.X, 0,targDirection.Z), new Vector3(cameraDirection.X, 0, ~ cameraDirection.Z)); Vector3 perpDirection = Vector3.Cross(upVector, cameraDirection); if (perpDirection == new Vector3()) perpDirection = new Vector3(0, 1, 0); perpDirection.Normalize(); Vector3 midShiftDirecton = new Vector3(0, 1, 0) + perpDirection; bezMidPosition += cameraDirection.Length() * midShiftDirecton; bezStartTarget = startTarget; bezEndTarget = endTarget; bezTime = 0.0f; } 当运行时,每帧都要调用UpdateBezier方法计算相机的新位置和旋转: private void UpdateBezier() { bezTime += 0.01f; if (bezTime > 1.0f) return; float smoothValue = MathHelper.SmoothStep(0, 1, bezTime); Vector3 newCamPos = Bezier(bezStartPosition, bezMidPosition, bezEndPosition, smoothValue); Vector3 newCamTarget = Vector3.Lerp(bezStartTarget, bezEndTarget, smoothValue); float updownRot; float leftrightRot; AnglesFromDirection(newCamTarget - newCamPos, out updownRot, out leftrightRot); fpsCam.UpDownRot = updownRot; fpsCam.LeftRightRot = leftrightRot; fpsCam.Position = newCamPos; } 位置是由Bezier方法计算的: private Vector3 Bezier(Vector3 startPoint, Vector3 midPoint, Vector3 endPoint, float time) { float invTime = 1.0f - time; float timePow = (float)Math.Pow(time, 2); float invTimePow = (float)Math.Pow(invTime, 2); Vector3 result = startPoint * invTimePow; result += 2 * midPoint * time * invTime; result += endPoint * timePow; return result; } 旋转由下面的方法获取: private Vector3 Bezier(Vector3 startPoint, Vector3 midPoint, Vector3 endPoint, float time) { float invTime = 1.0f - time; float timePow = (float)Math.Pow(time, 2); float invTimePow = (float)Math.Pow(invTime, 2); Vector3 result = startPoint * invTimePow; result += 2 * midPoint * time * invTime; result += endPoint * timePow; return result; }

在3D世界中创建不同的相机模式——检测相机与模型,墙或地形的碰撞

clock 十月 23, 2010 09:17 by author alex
2.6 检测相机与模型,墙或地形的碰撞 问题 你想检测相机是否已近靠近物体防止它穿过这个物体。 解决方案 要进行相机和模型的碰撞检测,你应将相机表示为一个包围球,使用这个包围球检查与模型的包围球或包围盒的碰撞。对应用户自定义的网格,诸如墙或地形,你必须首先找到墙或地形上的三角形任意位置的精确3D坐标。 工作原理 相机碰撞可分为与模型的碰撞和与已知顶点高度的三角形网格的碰撞。 相机与模型间的碰撞 这种情况中,你将相机作为一个球,只需调整球的半径就可以设置模型和相机间的最小距离。要检测相机球和模型的碰撞,你还需要模型的包围球或包围盒,这个球可以使用LoadModelWithBoundingSphere方法计算,这个方法在教程4-5中介绍,它把包围球存储在模型的Tag属性中。这个Tag属性可以存储模型的任意对象,也适合存储模型的包围球。在LoadContent方法中使用如下代码: myModel = XNAUtils.LoadModelWithBoundingSphere(ref modelTransforms, "tiny",content); 无论是移动相机还是模型都需进行碰撞检测,如果检测到碰撞,你将通过将相机位置恢复到初始值取消相机的最后一次改变。在Update方法中,保证你已经保存了初始值: protected override void Update(GameTime gameTime) { GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); if (gamePadState.Buttons.Back == ButtonState.Pressed) this.Exit(); MouseState mouseState = Mouse.GetState(); KeyboardState keyState = Keyboard.GetState(); Vector3 lastCamPos = fpsCam.Position; fpsCam.Update(mouseState, keyState, gamePadState); base.Update(gameTime); } 更新相机后,你需要创建一个对应相机新位置的包围球,下面的代码中你将包围球的最小距离设置为1个单位: float minimumDistance = 1.0f; BoundingSphere cameraSphere = new BoundingSphere(fpsCam.Position, minimumDistance); 有了更新过的相机包围球和模型包围球,就可以检测两者间的相交。如果检测到相交则将相机恢复到之前的位置: BoundingSphere origModelSphere = (BoundingSphere)myModel.Tag; BoundingSphere transModelSphere = XNAUtils.TransformBoundingSphere (origModelSphere, worldMatrix); if (cameraSphere.Contains(transModelSphere) !=ContainmentType.Disjoint) fpsCam.Position = lastCamPos; 如果模型通过世界矩阵移动到另一个位置或进行了缩放,你也应该相应地移动/缩放包围球,这可以通过使用TransformBoundingSphere实现。 接下来的代码检测相机的包围球与模型包围球的距离是否小于1个单位。 相机和地形间的碰撞 如果你想检测相机与你自定义的顶点网格(如地形)的碰撞,你需要使用三角形碰撞。本章使用的方法适用于顶点纵横坐标是等间距的情况。 如果顶点的高度信息存储在Y坐标中,这意味着所有顶点的X和Z坐标间的距离是相同的,因为这个原因,你可以知道相机是在哪个正方形的上方。例如,相机位置是(10.3f, 8.7f, 5.1f),你可以知道相机是在由四个顶点(10, .., 5),(11, .., 5), (10, ..., 6)和 (11, ..., 6)构成的正方形上方某高度,括号中的省略号表示这些顶点的高度,这些高度是在创建网格时定义的。你将这些高度存储在一个独立的数组中,或者你也可以从顶点缓冲获取这些高度(这不是首选方法)。 但是,如何知道相机与下方地形的高度差?最简单的方法是将相机位置截取到顶点的最近位置并检查相机是否在顶点的上方,如果相机在地形下方,则要相应调整相机的高度: protected override void Update(GameTime gameTime) { GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); if (gamePadState.Buttons.Back == ButtonState.Pressed) this.Exit(); MouseState mouseState = Mouse.GetState(); KeyboardState keyState = Keyboard.GetState(); fpsCam.Update(mouseState, keyState, gamePadState); float treshold = 3.0f; float terrainHeight = terrain.GetClippedHeightAt(fpsCam.Position.X, -fpsCam.Position.Z); if (fpsCam.Position.Y < terrainHeight + treshold) { Vector3 newPos = fpsCam.Position; newPos.Y = terrainHeight + treshold; fpsCam.Position = newPos; } base.Update(gameTime); } GetClippedHeightAt方法返回地形顶点的高度,相关知识可见教程5-9。你可以通过改变threshold值设置相机和地形间高度差的最小阈值,以上代码会调整相机的高度,使相机与地形之间的高度差保持在threshold。 注意GetClippedHeightAt方法的两个参数都是正值,因为这两个参数要作为查询矩阵的索引,而向前方向是负Z轴方向,所以你需要将Z坐标前加个“-”号使它变为正值。 因为高度是从邻近顶点获取的,因此GetClippedHeightAt方法会返回不同的值,以上代码会导致相机在三角形上移动时产生跳跃。要保证过渡平滑,你可以使用地形对象的GetExactHeightAt方法,这个方法在教程5-9解释,此方法可以获取地形上低于相机位置的点的精确高度: float terrainHeight = terrain.GetExactHeightAt(fpsCam.Position.X, -fpsCam.Position.Z); 当相机或模型是缓慢地在地形上移动时这个方法更好。 相机和墙之间的碰撞 墙通常只由两个三角形构成,所以它是相机-地形碰撞的简化形式。你可以使用教程5-9中介绍的GetExactHeightAt方法获取相机前方的墙的精确3D点。 代码 前面已经有了Update方法的全部代码了。

在3D世界中创建不同的相机模式——检查对象是否可见

clock 十月 18, 2010 21:32 by author alex
2.5 检查对象是否可见 问题 你想检查对象是否可见来决定是否要绘制这个物体。 解决方案 XNA拥有BoundingFrustum类支持这个功能,你可以通过传递View和Projection矩阵创建一个BoundingFrustum类,然后就可以很容易地在这个类中检查对象是否包含在视锥体中。 工作原理 视锥体定义了相机可以看到的范围,类似于削去顶部的金字塔,如图2-4所示,它的边是由视域角,近裁平面和远裁平面决定的,更多的信息可见教程2-1。 你应该让XNA只绘制包含在视锥体中的物体,否则,你会浪费显卡的处理能力。所以你需要一个方法用来检测对象是否在视锥体中。 XNA Framework中的BoundingFrustum类包含了实现这个功能完整方法,你只需通过指定相机的观察矩阵和投影矩阵创建BoundingFrustum类即可。 图2-4 相机的视锥体 BoundingFrustum对象允许你使用它的Contain方法,你可以传递一个Vector3和一个BoundingSphere或BoundingBox检查是否包含在相机的视锥中。这个方法会返回一个Containment Type对象,这个对象包含三个值: Contains:完整包含在视锥体中的测试对象。 Intersects:部分包含在视锥体中的测试对象,这时这个对象与视锥体相交。 Disjoint:不在视锥体中的测试对象。 检查3D空间中的点是否在相机可视范围内 要检查一个点是否可见,只需将这个点传递到Contains方法: Vector3 pointToTest = new Vector3(0, 0, 0); BoundingFrustum cameraFrustum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); ContainmentType containmentType = cameraFrustum.Contains(pointToTest); if (containmentType != ContainmentType.Disjoint) { Window.Title = "Point inside frustrum"; graphics.GraphicsDevice.Clear(Color.CornflowerBlue); } else { Window.Title = "Point outside frustrum"; graphics.GraphicsDevice.Clear(Color.Red); } 这个例子中你检查了点(0,0,0)是否在视锥体中,将检查结果写在了窗口标题栏中并相应地改变背景颜色。 检查一个物体是否可见 如果你想检查一个物体是否可见,一个方法是事先检查模型的每个顶点,这样做非常耗时。更快的方法(尽管不太确切)是定义一个包含模型的包围体,然后检查这个包围体是否在视锥体中。能包含模型的最简单的包围体是一个球。 XNA已在BoundingSphere类中支持创建一个包围体,使用教程4-5中介绍的LoadModelWithBoundingSphere方法载入模型,并在Tag属性中保存BoundingSphere: myModel = XNAUtils.LoadModelWithBoundingSphere(ref modelTransforms, "content/tiny", content); 技巧:因为模型的Tag属性可以存储任何类型的数据结构,你可以用让它存储任何与模型相关的数据,例如BoundingSphere,纹理等。你甚至还可以创建一个包含所有数据的结构并将这个结构存储在Tag属性中。 现在有了包围体你就可以测试它是否在视锥体中了: BoundingFrustum cameraSight = new BoundingFrustum(fpsCam.ViewMatrix* fpsCam.ProjectionMatrix); ContainmentType containmentType = cameraSight.Contains( (BoundingSphere)myModel.Tag); if (containmentType != ContainmentType.Disjoint) { Window.Title = "Point inside frustrum"; graphics.GraphicsDevice.Clear(Color.CornflowerBlue); Window.Title = "Point outside frustrum"; graphics.GraphicsDevice.Clear(Color.Red); } 检查一个设置了世界矩阵的对象在大多数情况中,你的模型并不在(0,0,0)位置而且还可能旋转,缩放或通过设置世界矩阵放置在其他位置,更多信息可见教程4-2。显然包围体也应该旋转/缩放/移动已匹配模型。这可以通过模型的世界矩阵变换包围体实现: Matrix worldMatrix = Matrix.CreateScale(0.01f, 0.01f, 0.01f) *Matrix.CreateTranslation(5, 0, 0); BoundingFrustum cameraSight = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); BoundingSphere origSphere = (BoundingSphere)myModel.Tag; BoundingSphere transSphere = origSphere.Transform(worldMatrix); ContainmentType containmentType = cameraSight.Contains(transSphere); if (containmentType != ContainmentType.Disjoint) { Window.Title = "Model inside frustrum"; graphics.GraphicsDevice.Clear(Color.CornflowerBlue); myModel.CopyAbsoluteBoneTransformsTo(modelTransforms); foreach (ModelMesh mesh in myModel.Meshes) { foreach (BasicEffect effect in mesh.Effects) { effect.EnableDefaultLighting(); effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix; effect.View = fpsCam.ViewMatrix; effect.Projection = fpsCam.ProjectionMatrix; } mesh.Draw(); } Window.Title = "Model outside frustrum"; graphics.GraphicsDevice.Clear(Color.Red); } 注意可见教程第三章学习如何绘制一个模型。这个代码中的主要变化是你调用了origSphere. Transform方法,这个方法获取经过世界变换的BoundingSphere。本例中transSphere的大小是origSphere百分之一,中心沿x轴移动了5个单位。只有当模型在视锥体时才会绘制这个模型。 代码 下面的代码会在一个指定位置绘制一个模型,当它在视锥体之外就不会被绘制: protected override void LoadContent() { device = graphics.GraphicsDevice; basicEffect = new BasicEffect(device, null); cCross = new CoordCross(device); myModel = XNAUtils.LoadModelWithBoundingSphere(ref modelTransforms,"content/tiny", content); } protected override void Draw(GameTime gameTime) { Matrix worldMatrix = Matrix.CreateScale(0.01f,0.01f,0.01f)*Matrix.CreateTranslation(5, 0, 0); BoundingFrustum cameraSight = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); BoundingSphere origSphere = (BoundingSphere)myModel.Tag; BoundingSphere transSphere = origSphere.Transform(worldMatrix); ContainmentType containmentType = cameraSight.Contains(transSphere); if (containmentType != ContainmentType.Disjoint) { Window.Title = "Model inside frustrum"; graphics.GraphicsDevice.Clear(Color.CornflowerBlue); //draw model myModel.CopyAbsoluteBoneTransformsTo(modelTransforms); foreach (ModelMesh mesh in myModel.Meshes) { foreach (BasicEffect effect in mesh.Effects) { effect.EnableDefaultLighting(); effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix; effect.View = fpsCam.ViewMatrix; effect.Projection = fpsCam.ProjectionMatrix; } mesh.Draw(); } } else { Window.Title = "Model outside frustrum"; graphics.GraphicsDevice.Clear(Color.Red); } //draw coordcross cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix); base.Draw(gameTime); } 扩展阅读 这个教程提出了一个检查点或物体是否在视锥体中的方法。但是如果场景中有大量的物体,那么要对每个对象进行检查会使程序变得很慢。在教程2-9会介绍一个更好的方法处理大场景。

在3D世界中创建不同的相机模式——创建一个第一人称射击游戏(FPS)的相机:Quake风格的相机

clock 十月 18, 2010 21:17 by author alex

[更多...]

在3D世界中创建不同的相机模式——创建一个Freelancer风格的相机:使用四元数的3D旋转

clock 十月 18, 2010 21:15 by author alex
2.4 创建一个Freelancer风格的相机:使用四元数的3D旋转 问题 译者注:《Freelancer》应该是指Digital Anvil制作,微软发行的游戏,中文译名《自由枪骑兵》,好像曾获2003年E3大展最佳PC游戏奖,是一个太空冒险游戏。 你想创建一个可以朝任意方向旋转的相机,例如,一个空战游戏。你需要能绕着三根轴旋转才能实现这点,但是因为万向节锁(Gimbal lock)(一个旋转会对影响另一个旋转)的缘故,这非常难,但并不是不可能。 解决方案 绕着多个轴组合一些旋转会导致万向节锁的发生,这会导致不正确的结果。使用四元数(quaternion)存储相机的旋转可以避免这个问题。 工作原理 当你组合绕着两个不同轴的旋转时,会发生万向节锁。发生的原因是第一个旋转会旋转第二个轴,解释可参见教程4-2。在这个教程中,你想让相机绕着x,y和z轴旋转。这会导致第二个轴被第一个旋转影响,第三个轴被前两个旋转的组合影响,所以,最后的结果难以预料。 旋转的一个特点是总存在一个旋转,这个单一旋转是多个旋转的组合。所以,这里需要的技巧是只定义一个旋转轴。这可以是任意轴:不一定是x, y或z轴。这根轴和旋转量被存储到一个变量中,叫做四元数 (quaternion)。 四元数可以很好的储存一个旋转,因为它可以存储一个轴和一个数,这个数保存绕轴旋转的角度。 下一个问题是,如何计算这个轴?答案是你不需要计算这个轴。过程中会自动为你创建。你只需指定一个初始轴,当用户移动鼠标时更新这个轴,你会在下面的例子中看到。 比方说将相机的Up轴作为旋转轴,旋转角度为0,这样你将看向前方。然后,根据用户输入,你想让相机绕着Right向量旋转。Right向量首先用当前相机的旋转值进行旋转,但因为是0度,所以Right向量保持不变。这个Right向量成为当前的旋转轴,相机将绕着这个轴旋转,会向上看。 如果接下来用户想让相机绕着Forward旋转。这根轴首先绕着当前相机的旋转轴旋转,此时已经不为0了而是包含绕Right轴的旋转。组合这两个旋转,旋转轴和旋转量存储到了相机四元数中。现在这个旋转成为了当前相机的旋转。 对用户的每个输入,发生同样的过程:Forward/Right/Up/ Down/. . .向量被相机的当前旋转轴旋转,这个新旋转和当前相机的旋转组合起来在四元数中储存为一个新的相机旋转,之后相机就绕着这个新轴旋转。 幸运的是,整个过程可以转化为一些简单的代码。首先,你只允许绕着一根轴旋转 (绕着Right轴): float updownRotation = 0.0f; KeyboardState keys = Keyboard.GetState(); if (keys.IsKeyDown(Keys.Up)) updownRotation = 1.5f; if (keys.IsKeyDown(Keys.Down)) updownRotation = -1.5f; Quaternion additionalRotation = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation); cameraRotation = cameraRotation * additionalRotation; 首先,用户输入决定相机向上还是向下旋转。然后,创建一个新的四元数保存这个轴和旋转量。最后,这个新旋转和当前相机的旋转组合在一起,将结果储存为一个新的相机旋转。 注意:四元数(quaternion)这个词通常会让程序员望而生畏,我也看到过很多文章和网站将四元数看成黑暗魔法,很难被形象理解。但这不是真的!一个四元数用来存储一个绕一根轴的旋转,所以它需要能够存储这个轴和旋转角度。轴由三个数字定义,旋转角度需要一个。所以,如果你存储了这样一个旋转,最简单的方法是存储四个数字。猜一下一个四元数实际上是什么?很简单,就是四个数字。没什么神秘的。但用到的数学知识非常复杂。 要让相机可以任意旋转,你需要能够绕第二根轴旋转。这个旋转需要存储在additionalRotation变量中,要乘以绕着第二根轴的旋转和第一根轴的旋转的组合: float updownRotation = 0.0f; float leftrightRotation = 0.0f; KeyboardState keys = Keyboard.GetState(); if (keys.IsKeyDown(Keys.Up)) updownRotation = 0.05f; if (keys.IsKeyDown(Keys.Down)) updownRotation = -0.05f; if (keys.IsKeyDown(Keys.Right)) leftrightRotation = -0.05f; if (keys.IsKeyDown(Keys.Left)) leftrightRotation = 0.05f; Quaternion additionalRotation =Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation)* Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), leftrightRotation); cameraRotation = cameraRotation * additionalRotation; 注意:使用矩阵乘法时,乘以四元数的顺序很重要。要理解为什么这么重要,可参见教程4-2中的例子。但是对四元数来说,当两个旋转轴相互垂直时,规则有个例外,如在计算 additionalRotation 变量的一行代码中的情况。这意味着无论是先绕(1,0,0) Right向量旋转还是绕(0,1,0) Up向量旋转,或其他旋转,结果都是一样的。但这不是计算cameraRotation变量的情况,因为两个旋转轴并不互相垂直。所以,你写成cameraRotation*additionalRotatio是很重要的。 注意:在矩阵乘法中,“*”的意思是“在……之后”,但在四元数乘法中表示“之前”。所以,代码cameraRotation = cameraRotation * additionalRotation就是cameraRotation在additionalRotation之前, 意思是存储在additionalRotatio变量中的轴会首先绕着存储在cameraRotation 中的轴旋转。 找到了旋转矩阵,相机的View矩阵就可以创建了,可参见教程2-2: private void UpdateViewMatrix() { Vector3 cameraOriginalTarget = new Vector3(0, 0, -1); Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0); Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation); Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget; Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation); viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraRotatedUpVector); } 例如,当用户按下箭头键时,移动相机可以通过首先通过相机旋转量旋转移动向量,然后将这个变换添加到当前相机位置上实现: float moveSpeed = 0.5f; Vector3 rotatedVector =Vector3.Transform(vectorToAdd, cameraRotation); cameraPosition += moveSpeed * rotatedVector; UpdateViewMatrix(); 代码 因为这种相机通常使用在一个太空游戏中,如果你想创建一个太空射击游戏而且3D世界需要从飞船中的飞行员观察时,这个代码可以立即被使用。用户可以通过键盘或鼠标控制相机的旋转,而相机会自动向前移动。键盘的例子已经给出了,你可以在教程2-3中找到鼠标控制的代码。 要实现基于四元数的相机,只需要两个变量:相机的当前位置和旋转。 protected override void Initialize() { cCross = new CoordCross(); base.Initialize(); float viewAngle = MathHelper.PiOver4; float aspectRatio = graphics.GraphicsDevice.Viewport.AspectRatio; float nearPlane = 0.5f; float farPlane = 100.0f; projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane, farPlane); cameraPosition = new Vector3(-1, 1, 10); cameraRotation = Quaternion.Identity; UpdateViewMatrix(); } protected override void Update(GameTime gameTime) { if (GamePad.GetState(PlayerIndex.One).Buttons.Back ButtonState.Pressed) this.Exit(); float updownRotation = 0.0f; float leftrightRotation = 0.0f; KeyboardState keys = Keyboard.GetState(); if (keys.IsKeyDown(Keys.Up)) updownRotation = 0.05f; if (keys.IsKeyDown(Keys.Down)) updownRotation = -0.05f; if (keys.IsKeyDown(Keys.Right)) leftrightRotation = -0.05f; if (keys.IsKeyDown(Keys.Left)) leftrightRotation = 0.05f; Quaternion additionalRotation = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation) * Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), leftrightRotation); cameraRotation = cameraRotation * additionalRotation; AddToCameraPosition(new Vector3(0, 0, -1)); base.Update(gameTime); } private void AddToCameraPosition(Vector3 vectorToAdd) { float moveSpeed = 0.05f; Vector3 rotatedVector = Vector3.Transform(vectorToAdd, cameraRotation); cameraPosition += moveSpeed * rotatedVector; UpdateViewMatrix(); } private void UpdateViewMatrix() { Vector3 cameraOriginalTarget = new Vector3(0, 0, -1); Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0); Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation); Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget; Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation); viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraRotatedUpVector); } 扩展阅读 跟随一个物体的相机 你可以使用前面的代码创建一个太空游戏,这个游戏中的相机放置在飞船的座舱中。但是,如果你想把相机放置在飞船之后,那么需要其他代码。在这种情况中,你要根据飞船而不是相机存储位置和旋转。相机的位置是基于飞船的位置计算的。对飞船来说,你可以使用前面的代码,这可以让飞船绕任意方向旋转并向前飞行,你想让相机位于飞船之后。记住,要定义相机的View矩阵,需要三个向量:相机的Position,Target和Up向量,你可以参见教程2-1学习相关知识。如果你想将一个相机放置在飞船后面,你需要将相机看着飞船。所以,你已经知道了View矩阵的Target向量。相机的Up方法与飞船相同,这个向量可以取默认的(0,1,0)Up向量,使用飞船的旋转量绕它旋转。要解释的最后一个向量是相机的Position。你想让相机位于飞船后面,因为(0,0,-1)向量通常作为Forward向量,(0,0,1)表示Behind向量。首先,这个向量需要使用飞船的旋转量进行旋转,这样Behind向量变成了相对于飞船的Behind向量。然后,你需要将这个向量添加到飞船的位置中,这样当飞船的位置改变时相机的位置也会发生改变。下面是代码: protected override void Update(GameTime gameTime) { if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ~ ButtonState.Pressed) this.Exit(); float updownRotation = 0.0f; float leftrightRotation = 0.0f; KeyboardState keys = Keyboard.GetState(); if (keys.IsKeyDown(Keys.Up)) updownRotation = 0.05f; if (keys.IsKeyDown(Keys.Down)) updownRotation = -0.05f; if (keys.IsKeyDown(Keys.Right)) leftrightRotation = -0.05f; if (keys.IsKeyDown(Keys.Left)) leftrightRotation = 0.05f; Quaternion additionalRotation = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation) * Quaternion.CreateFromAxisAngle(new ~ Vector3(0, 1, 0), leftrightRotation); spacecraftRotation = spacecraftRotation * additionalRotation; AddToSpacecraftPosition(new Vector3(0, 0, -1)); base.Update(gameTime); } private void AddToSpacecraftPosition(Vector3 vectorToAdd) { float moveSpeed = 0.05f; Vector3 rotatedVector = Vector3.Transform(vectorToAdd, spacecraftRotation); spacecraftPosition += moveSpeed * rotatedVector; UpdateViewMatrix(); } 在UpdateViewMatrix方法中,相机位置作为spacecraftPosition的后面被计算: private void UpdateViewMatrix() { Vector3 cameraOriginalPosition = new Vector3(0, 0, 1); Vector3 cameraRotatedPosition = Vector3.Transform(cameraOriginalPosition, spacecraftRotation); Vector3 cameraFinalPosition = spacecraftPosition + cameraRotatedPosition; Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0); Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, spacecraftRotation); viewMatrix = Matrix.CreateLookAt(cameraFinalPosition, spacecraftPosition, cameraRotatedUpVector); } 在Draw过程中,你需要根据当前位置和旋转量平移和旋转飞船: Matrix worldMatrix = Matrix.CreateScale(0.001f, 0.001f, 0.001f)*Matrix.CreateFromQuaternion(spacecraftRotation)* Matrix.CreateTranslation(spacecraftPosition); spacecraftModel.CopyAbsoluteBoneTransformsTo(modelTransforms); foreach (ModelMesh mesh in spacecraftModel.Meshes) { foreach (BasicEffect effect in mesh.Effects) { effect.EnableDefaultLighting(); effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix; effect.View = viewMatrix; effect.Projection = projectionMatrix; } mesh.Draw(); } 注意看模型的World矩阵是如何取决于存储在四元数的飞船旋转和位置的。你可以在第4章学习通过设置World矩阵在3D空间绘制和放置一个物体。

在3D世界中创建不同的相机模式——指定相机的目标

clock 十月 18, 2010 21:05 by author alex
2.2 指定相机的目标 问题 当定义View矩阵时,你需要指定Target向量作为参数,这个向量设置了相机的旋转。 解决方案 当选转相机时,相机的位置会保持不变。这意味着旋转取决于相机的Target点和Up向量的变化。你可以通过使用 (0,0,-1)Forward向量并将旋转施加到这个向量上获取Target向量,这会产生一个新的Target点。你可以使用相同的方法获取Up向量。 工作原理 如教程2-1所示,一个相机需要Position, Target和Up向量才能唯一确定。如果你想让相机绕着某一点旋转,Position向量保持不变,但Target和Up向量都要发生改变。 给定绕着三根轴的旋转角度,一个方法是处理Target位置,但这会带来复杂的运算,还有一个更加清晰和快速的方法。 最简单的例子:相机在初始位置,绕着Up向量 旋转让我们先来看一下相机在(0,0,0)初始坐标的情况。它朝(0,0,-1) Forward方向观察,以默认的(0,1,0) Up向量作为向上方向。在这个情况中,你可以使用以下代码: Vector3 cameraPosition = new Vector3(0, 0, 0); Vector3 cameraTarget = new Vector3(0, 0, -1); Vector3 cameraUpVector = new Vector3(0, 1, 0); viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraTarget, cameraUpVector); 例如你想创建一个绕Up向量旋转45度的View矩阵。如果你的头就是相机,这会让头部向右旋转45度。当计算新View矩阵时,Position向量和Up向量会保持不变,但你需要获取新的Target向量。你可以通过将使用45度旋转“转换”默认的(0,0,-1) Target向量获取新Target向量。这意味着你获取的这个向量是初始Target向量的旋转版本。下面是代码: Matrix cameraRotation = Matrix.CreateRotationY(MathHelper.PiOver4); Vector3 cameraPosition = new Vector3(0, 0, 0); Vector3 cameraUpVector = new Vector3(0, 1, 0); Vector3 cameraOriginalTarget = new Vector3(0, 0, -1); Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation); viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraRotatedTarget, cameraUpVector); 注意:矩阵是表示某种变换的有力工具。一个矩阵可以表示旋转,平移,缩放或这些变换的组合。你可以在教程4-2中获取更多的例子。 第一行代码创建了一个表示绕Up轴旋转45度的矩阵,对应PI/4弧度。下面的代码通过这个旋转矩阵转换初始(0,0,-1) Target向量,并将旋转过的Target向量保存在cameraRotatedTarget变量中,这个变量用来创建一个新的View矩阵。 注意:这个变换并不神秘;它是向量和矩阵的乘积,只是简单地进行16次乘法和12次加法。 第二个例子:相机在初始位置,任意旋转 现在来看一个有点复杂的例子。你想使相机绕着任意轴旋转而不是绕着Up向量。例如,绕着(1,0,0) Right轴旋转45度,如果你的头是相机,这会导致斜向上45度观察。 因为你仅旋转相机,Position向量保持不变。和前面一个例子一样,Target向量会变化,因为相机需要观察一个不同的位置。 但是这种情况中Up向量也会发生变化。在前面的例子中你将头部转向右边,Up向量并不会变化。这个例子中,将头部向上旋转,Up向量和Target向量都要发生改变。 变换Up向量和变换Forward向量的方法是一样的:你将旋转存储在一个矩阵中并定义初始Up向量。然后,通过这个旋转矩阵变换初始向量获取新的Up向量。下面是代码: Matrix cameraRotation = Matrix.CreateRotationX(MathHelper.PiOver4); Vector3 cameraPosition = new Vector3(0, 0, 0); Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0); Vector3 cameraOriginalTarget = new Vector3(0, 0, -1); Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation); Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation); viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraRotatedTarget, cameraRotatedUpVector); 这里的“任意”旋转矩阵只是一个简单绕x轴的旋转。这个代码也可以处理任何旋转,例如下面的这个情况,它组合了三根轴上的旋转。下面的代码生成一个矩阵,这个矩阵是绕z轴–45度和y轴22.5度,x轴90度旋转的组合: Matrix cameraRotation = Matrix.CreateRotationX(MathHelper.PiOver2)* Matrix.CreateRotationY(MathHelper.Pi/8.0f)* Matrix.CreateRotationZ(-MathHelper.PiOver4); 当获取新的Forward向量和Up向量时,相机的位置保持不变。 第三个例子:相机在指定位置,任意旋转 在大多数情况中,你想设置任意旋转并指定相机的位置。例如相机在位置(10,20,30),不旋转。非常简单,相机的Position向量为(10,20,30)。因为没有旋转,所以相机观察(0,0,-1) Forward方向。 注意:记住需要指定Target而不是Target方向!将(0,0,-1)作为Target向量是错误的,因为这会让相机观察点(0,0,-1)。例如你将相机移动到点(-10,20,30),如果仍然指定(0,0,-1)作为Target向量,相机仍会观察点(0,0,-1),这个相机观察的方向就会发生变化! 要让位于(10,20,30)相机朝向(0,0,-1)方向,你需要指定(10,20,29)作为Target向量。你可以通过求相机位置和目标方向的和获取这个向量: Vector3 cameraPosition = new Vector3(10, 20, 30); Vector3 cameraOriginalTarget = new Vector3(0, 0, -1); cameraTargetPoint = cameraPosition + cameraOriginalTarget; 现在,可以组合前面所学到的东西了。你将定义一个位于(10,20,30)的相机并可以任意旋转。Position向量保持(10,20,30)不变。对Target向量,首先定义为(0,0,-1)方向。要在旋转后获取Forward方向,你需要使用旋转矩阵变换它。最后,要获取Target向量让位于(10,20,30)的相机看向旋转方向,你需要在这个旋转方向上加上(10,20,30)。Up向量用同样的方法获取,下面是最后的代码: Matrix cameraRotation =Matrix.CreateRotationX(MathHelper.PiOver2)* Matrix.CreateRotationY(MathHelper.Pi/8.0f)*Matrix.CreateRotationZ(-MathHelper.PiOver4); Vector3 cameraPosition = new Vector3(10, 20, 30); Vector3 cameraOriginalTarget = new Vector3(0, 0, -1); Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0); Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation); Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget; Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation); Vector3 cameraFinalUpVector = cameraPosition + cameraRotatedUpVector; viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraFinalUpVector); 将旋转过的相机前后/左右移动 现在已经实现了指定位置的相机并朝向正确的方向,新的挑战是前后移动相机。如果你想让相机向前移动,简单地将(0,0,-1) Forward向量添加到Position向量上不会成功,因为首先需要获取对应旋转相机的Forward向量。你已经在本教程中的第一个例子中使用过旋转矩阵变换过(0,0,-1) Forward向量了,有了这个变换过的Forward向量,才可以将它加到Position向量中: float moveSpeed = 0.5f; Vector3 cameraOriginalForward = new Vector3(0,0,-1); Vector3 cameraRotatedForward = Vector3.Transform(cameraOriginalForward, cameraRotation); cameraPosition += moveSpeed * cameraRotatedForward; 改变moveSpeed的值可以增加/减少相机移动的速度,因为这个值会乘以旋转过的 Forward方向。 同样的方法也可以让相机左右移动。处理的是(1,0,0) Right向量而不是(0,0,-1) Forward向量,仍然需要首先进行变换以获取对应当前相机旋转的Right向量。 float moveSpeed = 0.5f; Vector3 cameraOriginalRight = new Vector3(1, 0, 0); Vector3 cameraRotatedRight = Vector3.Transform(cameraOriginalRight, cameraRotation); cameraPosition += moveSpeed * cameraRotatedRight; 代码 这个方法中相机只需保存当前位置和旋转,位置和旋转任意一个发生变化就要更新View 矩阵。这些变化通常来自于用户输入,可以在教程2-3和2-4中看到具体实现。每个矩阵都需要进行初始化,你首先需将cameraRotation矩阵设置为单位矩阵。 protected override void Initialize() { float viewAngle = MathHelper.PiOver4; float aspectRatio = graphics.GraphicsDevice.Viewport.AspectRatio; float nearPlane = 0.5f; float farPlane = 100.0f; projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane, farPlane); cameraPosition = new Vector3(-5, 7, 14); cameraRotation = Matrix.CreateRotationX(-MathHelper.Pi/8.0f)* Matrix.CreateRotationY(-MathHelper.Pi/8.0f); UpdateViewMatrix(); base.Initialize(); } protected override void Update(GameTime gameTime) { if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); MoveCameraForward(); base.Update(gameTime); } private void MoveCameraForward() { float moveSpeed = 0.05f; Vector3 cameraOriginalForward = new Vector3(0, 0, -1); Vector3 cameraRotatedForward = Vector3.Transform(cameraOriginalForward, cameraRotation); cameraPosition += moveSpeed * cameraRotatedForward; UpdateViewMatrix(); } private void UpdateViewMatrix() { Vector3 cameraOriginalTarget = new Vector3(0, 0, -1); Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0); Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation); Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget; Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation); viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraRotatedUpVector); } protected override void Draw(GameTime gameTime) { graphics.GraphicsDevice.Clear(Color.CornflowerBlue); //render coordcross using specified View and Projection matrices cCross.Draw(viewMatrix, projectionMatrix); base.Draw(gameTime); } 扩展阅读 矩阵乘法的顺序 可参见教程4-2。 改变坐标系统 前面使用的Forward (0,0,-1)和Up (0,1,0)方法是“官方的” XNA向量,也可以使用Vector3 . Forward和Vector3 . Up快捷方式获取。但是这只是一个约定,你可以定义一个完全不同的坐标系统。例如也可以使用(0,0,1)作为Up方向,(0,1,0)作为Forward方向,(1,0,0)作为Right方向,这完全取决于你的需要。 但是这三个向量应该符合一个规则。在XNA中x,y和z轴是右手坐标系。意思是一旦你知道了任意两个坐标轴,就可以知道第三个坐标轴的方向。展开你右手的大拇指和食指,然后弯曲中指使它垂直于食指和大拇指,将这三个手指看成坐标轴,如果是右手坐标系,那么x轴就对应大拇指,y轴对应食指,z轴对应中指。 本教程第一段的坐标系统,对应大拇指指向右方,中指指向上方 。要代表“官方”XNA坐标系统,将你的大拇指指向右方(正x轴 = 右方),食指指向上方 (y轴=上方)。现在你可以看到中指指向后方(正z轴 =后方), 这就是为什么官方Forward (0,0,-1)向量中有个负号的原因!

在3D世界中创建不同的相机模式——创建一个相机:Position,Target和View Frustum

clock 十月 17, 2010 20:35 by author alex
2.1 创建一个相机:Position,Target和View Frustum 问题 在将3D世界绘制到屏幕之前,你需要相机的View和Projection矩阵。 解决方案 你可以在一个矩阵中保存相机位置和方向,这个矩阵叫做View矩阵(视矩阵,观察矩阵)。要创建View矩阵,XNA需要知道相机的Position,Target和Up矢量。 你也可以保存视锥体(view frustum),它是3D世界中实际可见的部分,在另一个叫做Projection的矩阵中。 工作原理 View矩阵保存相机位置和观察方向的定义。你可以通过调用Matrix. CreateLookAt方法创建这个矩阵: viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, camUpVector); 这个方法需要三个参数:相机的Position, Target和Up向量。Position向量容易理解,它表示在3D空间中的哪个位置放置相机。然后,需要指定另一个点表示相机观察的目标。这已经可以定义一个相机了,但Up向量用来干什么? 看一下这个例子:你的头(事实上是你的眼睛)就是相机。你试着定义一个与头有相同位置和方向的相机。第一个向量很容易找到:Position向量就是头在3D场景中的位置。然后, Target向量也不是很难;假设你看着图2-1中的X,在这种情况中,X的位置就是相机的Target 向量。但有其他方式可以让在同一位置的头看着X! 图2-1 相机的观察目标 只定义了Position和Target向量 ,你也可以绕着双眼之间的点旋转头部,例如,上下颠倒看。如果你这样做,头部的位置和观察目标仍保持不变,但因为所有东西都进行了旋转,观察到的图像会完全不同。你就是为什么需要定义相机的Up向量的原因。 知道了相机的位置,观察目标和相机的up方向,相机就唯一确定了。View矩阵由这三个向量决定,可以使用Matrix. CreateLookAt方法创建一个相机: Matrix viewMatrix; Vector3 camPosition = new Vector3(10, 0, 0); Vector3 camTarget = new Vector3(0, 0, 0); Vector3 camUpVector = new Vector3(0, 1, 0); viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, camUpVector); 注意:相机的Position和Target向量指向3D空间中的真实位置,Up向量表示相机向上方向。例如,一个相机位于点(300,0,0)观察点(200,0,0)。如果相机的Up向量只是简单地向上,只需指定(0,1,0)Up向量,这不是指在3D空间中的点,这个例子中这个3D点为(300,1,0)。 注意:XNA为最常用的向量提供了一个快捷方式,Vector3. Up表示(0,1,0),Vector3. Forward表示(0,0,-1),Vector3. Right表示(1,0,0)。为了帮你理解3D向量,本章第一个教程都使用完整的写法。 XNA还需要Projection矩阵。你可以将这个矩阵看成可以映射从3D空间到2D窗口的所有点的一个东西,但我更希望你把它看成包含相机镜头信息的矩阵。 让我们看一下图2-2,左图显示了一个在相机视野中的3D场景,你可以看到它像一个金字塔。右图中你可以看到金字塔的一个2D切面。 图2-2 相机的视锥体 图片左边的切除顶部的金字塔叫做视锥体(view frustum)。只有在视锥体内部的物体才会被绘制到屏幕上。 XNA可以为你创建这样一个视锥体,它存储在Projection矩阵中。你可以调用Matrix. CreatePerspectiveFieldOfView创建这个矩阵: projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane, farPlane); Matrix. CreatePerspectiveFieldOfView方法的第一个参数是观察角度。它对应金字塔顶角的一半,如图2-2右图所示。如果你想知道自己的观察角度,可以将手放在眼睛前面,你会发现这个角度约为90度。因为弧度PI等于180度,90等于PI/2。因为你需要指定观察角度的一半,所以这个参数为PI/4。 注意:通常你想使用一个对应人的视角的视角,但是在某些场景中你可能会指定其他的视角。通常发生在你想将场景绘制到一张纹理的情况中,例如,从光线的视角看来。在光线的情况中,更大的视角表示更大的光照范围。对应的例子可参见教程3-13。 你需要指定的另一个参数与“source,”无关,即与视锥体无关,而和“destination,”有关,即与屏幕有关。它是2D屏幕的长宽比,它实际上对应后备缓冲的长宽比,你可以使用以下代码获取它: float aspectRatio = graphics.GraphicsDevice.Viewport.AspectRatio; 当使用一个长和宽相同的正方形窗口时,这个比为1。但是绘制全屏800 × 600窗口时这个比大于1,当绘制到宽屏笔记本或HDTV更大。如果你用错误地用1代替800/600作为800*600窗口的长宽比,图像会水平拉伸。 最后两个参数与视锥体有关。想象一下一个物体非常靠近相机,这个物体会占据整个视野,窗口会被一个单独的颜色占满。要避免这个情况的发生,XNA让你可以定义一个靠近金字塔顶部的平面。位于金字塔顶部和这个平面之间的物体不会被绘制,这个平面叫做近裁平面(near clipping plane),你可以指定相机到这个近裁平面的距离作为CreatePerspectiveFieldOfView方法的第三个参数。 注意:剪裁过程用来表示有些物体无需被绘制以提高程序帧频率。 同理也可以处理离相机非常远的物体;这些物体看起来很小,但仍占用显卡的处理时间。所以,远于第二个平面的物体也会被剪裁。第二个平面叫做远裁平面(far clipping plane),它是视锥体的最远边界。你可以指定相机到这个远裁平面的距离作为CreatePerspectiveFieldOfView方法的最后一个参数。 当心:即使绘制的是一个及其简单的3D场景,也不要把远裁平面设置地过大。例如将远裁平面的距离设置为比较疯狂的100000会导致一些视觉错误。带有16-bit深度缓冲的显卡(可参见本教程的“Z-Buffer (或Depth Buffer)”一节)有2^16 = 65535个深度值。如果两个物体使用同一个像素,而且之间的距离小于100k/65535 = 1.53个单位时,显卡就无法判断哪个物体更加靠近相机。 事实上,这会导致更坏的结果,因为scale is quadratic(?),会导致整个场景的最后三个 quarters(?)看起来离开相机的距离相同。近裁平面和远裁平面间的距离最好小于几百,如果显卡的深度缓冲小于16-bit,这个距离应该更小。 这个问题的典型错误就是你看到的所有对象都有锯齿边缘。 使用方法 你想在程序的更新过程中更新View矩阵,因为相机的位置和方向是基于用户输入的。Projection矩阵只需在窗口的长宽比发生变化时才需要更新,例如,当将窗口切换到全屏模式时。 计算好View和Projection矩阵之后,你需要将它们传递到绘制物体的effect中,可在下面的Draw方法中看到对应代码。这可以让显卡上的shader将所有的顶点转换为窗口的对应像素。 代码 下面的例子显示了如何创建一个View矩阵和一个Projection矩阵。比方说你有一个物体位于(0,0,0),你想将相机放置在x轴上+10个单位的地方,正y轴作为Up向量。 而且,你想在800 × 600窗口中绘制场景,使所有与相机的距离小于0.5f大于100.0f的三角形被剪裁。下面是代码: using System; using System.Collections.Generic; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Storage; namespace BookCode { public class Game1 : Microsoft.Xna.Framework.Game { GraphicsDeviceManager graphics; ContentManager content; BasicEffect basicEffect; GraphicsDevice device; CoordCross cCross; Matrix viewMatrix; Matrix projectionMatrix; public Game1() { graphics = new GraphicsDeviceManager(this); content = new ContentManager(Services); } 只有在窗口的长宽比发生变化时才需要更新Projection矩阵。你只需要定义一次Projection矩阵,所以放在程序的初始化过程中。 protected override void Initialize() { base.Initialize(); float viewAngle = MathHelper.PiOver4; float aspectRatio = graphics.GraphicsDevice.Viewport.AspectRatio; float nearPlane = 0.5f; float farPlane = 100.0f; projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane,farPlane); } protected override void LoadContent() { device = graphics.GraphicsDevice; basicEffect = new BasicEffect(device, null); cCross = new CoordCross(device); } protected override void UnLoadContent() { } 你需要改变View矩阵让用户输入移动相机,因此将它放在程序的更新过程中。 protected override void Update(GameTime gameTime) { if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); Vector3 camPosition = new Vector3(10, 10, -10); Vector3 camTarget = new Vector3(0, 0, 0); Vector3 camUpVector = new Vector3(0, 1, 0); viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, camUpVector); base.Update(gameTime); } 然后将Projection和View矩阵传递到Effect绘制场景: protected override void Draw(GameTime gameTime) { graphics.GraphicsDevice.Clear(Color.CornflowerBlue); basicEffect.World = Matrix.Identity; basicEffect.View = viewMatrix; basicEffect.Projection = projectionMatrix; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); cCross.DrawUsingPresetEffect(); pass.End(); } basicEffect.End(); base.Draw(gameTime); } 扩展阅读 只需要用到前面两个矩阵XNA就可以将3D场景绘制到2D屏幕中。从3D转换到2D是个挑战,但XNA已经帮你做到了。但是,要创建和调试更大的3D程序需要深入理解这个操作背后到底发生了什么事。 Z-Buffer (或Depth Buffer) 第一个挑战是指定哪个物体会占据最终图像上的像素。当从3D空间转换到2D屏幕时,有可能多个物体都显示在同一个像素上,如图2-3所示。2D屏幕上的一个像素对应3D空间中的一条射线,解释可参见教程4-14。对一个像素,这条射线如图2-3中的虚线所示,它与两个物体相交。这种情况中,这个像素的颜色会取自物体A,因为A比B更靠近相机。 图2-3 多个物体占据同一个像素 但是,如果首先绘制物体B,frame buffer中对应像素会首先被指定为B的颜色。然后物体A被绘制,显卡需要判断像素是否需要用物体A的颜色覆盖。 解决方法是,在显卡中还储存了第二张图像,它的大小与窗口大小一样。当给frame buffer中的一个像素指定一个颜色时,这个物体和相机间的距离会保存在第二个图像中。这个距离介于0和1之间,0对应近裁平面与相机间的距离,1对应远裁平面与相机间的距离。所以第二个图像叫做depth buffer或z-buffer。 那么如何解决这个问题?当绘制物体B时会检查z-buffe,因为B首先绘制,z-buffer是空的。结果是frame buffer中对应像素的颜色就是B的颜色,在z-buffer的相同像素中获得一个值,对应B物体与相机间的距离。 然后绘制物体A,对应物体A的每个像素,首先检查z-buffer。z-buffer已经包含了物体B的值,但储存在z-buffer中的距离大于物体A与相机间的距离,所以显卡知道需要用物体A的颜色覆盖这个像素!

XNA 3.0——将数据保存到文件,从文件读取数据

clock 十月 17, 2010 20:24 by author alex
1.9 将数据保存到文件,从文件读取数据 问题 你需要在游戏中实现保存功能。解决方案虽然保存功能通常是创建游戏的最后一步,但你肯定需要在游戏中实现这个功能。首先,XNA使用默认的.NET文件I/O 功能,这意味着创建/打开/删除文件是很容易的。接着,使用XmlSerializer类能非常容易地将数据保存到文件并在以后加载数据。 唯一的问题是找到在PC平台和Xbox360平台都能保存的位置,要解决这个问题,你可以使用StorageDevice,它必须首先被建立。 注意创建一个StorageDevice的原理可能很复杂,但别被吓倒,因为本教程的其余部分(从“将数据保存到磁盘”) 非常简单和有效,而且这个方法并不局限与在XNA上使用,因为它使用的是默认的.NET功能。 工作原理 在将数据保存到磁盘之前,你需要一个可用的位置进行写入。这可以通过创建一个StorageDevice实现,StorageDevice会询问Xbox360平台上的用户将数据保存到哪儿。 异步创建一个StorageDevice 在这个过程中会打开Xbox Guide并终止程序直到用户关闭Guide。要解决这个问题,可以让这个过程异步处理,原理在教程8-5中解释。 Guide需要将GamerServicesComponent添加到游戏中 (见教程8-1),所以要将下列代码添加到Game1构造函数中: public Game1() { graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; Components.Add(new GamerServicesComponent(this)); } 下一步,当调用Update方法时,你会检查用户是否想保存数据,如果是,则调用Guide. BeginShowStorageDeviceSelector,它会打开一个对话框让用户选择在哪里保存数据。程序并不会在此终止,但会暂停程序直到用户关闭对话框。Guide. BeginShowStorageDeviceSelector方法要求你指定另一个FindStorageDevice方法作为第一个参数,一旦用户关闭对话框这个方法就会被调用: KeyboardState keyState = Keyboard.GetState(); if (!Guide.IsVisible) if (keyState.IsKeyDown(Keys.S)) Guide.BeginShowStorageDeviceSelector(FindStorageDevice, null); 如果用户按下S键就会打开对话框,但代码并不会终止直到用户关闭对话框。 当对话框显示在屏幕上时你的程序仍在运行,一旦用户做出选择并关闭对话框,作为第一个参数的FindStorageDevice方法就会被调用,这意味着你应事先定义这个方法,否则编译器会报错: private void FindStorageDevice(IAsyncResult result) { StorageDevice storageDevice = Guide.EndShowStorageDeviceSelector(result); if (storageDevice != null) SaveGame(storageDevice); } BeginShowStorageDeviceSelector的输出结果作为这个方法的参数,如果你将结果传递到Guide. EndShowStorageDeviceSelector方法,就可以获取data storage。但是如果用户取消操作,结果将会是null,所以你应该事先检查。如果StorageDevice可用,你将它传递到SaveGame方法,这个方法一会儿后就会定义。 但如果当用户指定数据位置时允许用户进行第二种操作,例如加载数据,那么就需要定义第二个方法,比方说FindStorageDeviceForLoading。但清晰的方法是指定一个identifier,在FindStorageDevice方法中可以检查这个identifier。你的Update方法应该包含下列代码块: KeyboardState keyState = Keyboard.GetState(); if (!Guide.IsVisible) { if (keyState.IsKeyDown(Keys.S)) Guide.BeginShowStorageDeviceSelector(FindStorageDevice, "saveRequest"); if (keyState.IsKeyDown(Keys.L)) Guide.BeginShowStorageDeviceSelector(FindStorageDevice, "loadRequest"); } 如你所见,在这两种情况下对话框都会显示,当对话框关闭后就会调用FindStorageDevice方法。但这次的区别是你指定了一个identifier并在FindStorageDevice方法中检查这个identifier: private void FindStorageDevice(IAsyncResult result) { StorageDevice storageDevice = Guide.EndShowStorageDeviceSelector(result); if (storageDevice != null) { if (result.AsyncState == "saveRequest") SaveGame(storageDevice); else if (result.AsyncState == "loadRequest") LoadGame(storageDevice); } } 根据指定的identity,你将调用SaveGame或LoadGame方法。 将数据保存到磁盘 一旦有了一个正确的StorageDevice,你就可以很容易地为写入数据的文件指定一个名称: private void SaveGame(StorageDevice storageDevice) { StorageContainer container = storageDevice.OpenContainer("BookCodeWin"); string fileName = Path.Combine(container.Path, "save0001.sav"); FileStream saveFile = File.Open(fileName, FileMode.Create); } 这将创建一个叫做save0001. Sav的文件,如果这个文件已经存在将会被覆盖。 注意在PC上这个文件将会创建在My Documents\SavedGames文件夹中。 一旦你有了正确的文件名并已经打开了这个文件,你就可以使用默认的.NET 方法保存文件,假设你将存储的数据结构如下: public struct GameData { public int ActivePlayers; public float Time; } 你需要创建一个XmlSerializer,它能够将你的数据转换为XML并保存到磁盘: XmlSerializer xmlSerializer = new XmlSerializer(typeof(GameData)); xmlSerializer.Serialize(saveFile, gameData); saveFile.Close(); XmlSerializer能够串行化GameData对象,之后使用一个命令就可以将GameData对象的数据流写到文件中。别忘了关闭文件流,否则程序会锁定文件流。 你需要添加System. IO和System.Xml. Serialization命名空间,只需在代码顶部添加下面的代码: using System.IO; using System.Xml.Serialization; 最后一行代码需要你添加对System. Xml的引用,要添加这个引用,可以打开Project菜单并选择Add Reference,选择System.Xml,如图1-5所示,然后点击OK。 图1-5 添加对System. Xml的引用 从磁盘加载数据 加载数据的方法与保存数据的方法相同,顺序正好相反。你检查文件是否存在,如果存在就打开它。你仍要创建一个XmlSerializer,但这次你使用XmlSerializer从文件流中反串行化GameData对象,下面的代码加载从文件加载所有数据并把数据转换为GameData对象: private void LoadGame(StorageDevice storageDevice) { StorageContainer container = storageDevice.OpenContainer("BookCodeWin"); string fileName = Path.Combine(container.Path, "save0001.sav"); if (File.Exists(fileName)) { FileStream saveFile = File.Open(fileName, FileMode.Open); XmlSerializer xmlSerializer = new XmlSerializer(typeof(GameData)); gameData = (GameData)xmlSerializer.Deserialize(saveFile); saveFile.Close(); } } 代码 Update方法检查用户是想保存还是加载文件并打开对话框: protected override void Update(GameTime gameTime) { GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); if (gamePadState.Buttons.Back == ButtonState.Pressed) this.Exit(); KeyboardState keyState = Keyboard.GetState(); if (!Guide.IsVisible) { if (keyState.IsKeyDown(Keys.S)) Guide.BeginShowStorageDeviceSelector(FindStorageDevice, "saveRequest"); if (keyState.IsKeyDown(Keys.L)) Guide.BeginShowStorageDeviceSelector(FindStorageDevice, "loadRequest"); } gameData.Time += (float)gameTime.ElapsedGameTime.TotalSeconds; base.Update(gameTime); } 当用户关闭Guide时就会调用FindStorageDevice方法,这个方法根据异步调用的identity调用SaveData或LoadData方法。你可以在前面的代码中看到整个FindStorageDevice方法,只漏了SaveGame 方法: private void SaveGame(StorageDevice storageDevice) { StorageContainer container = storageDevice.OpenContainer("BookCodeWin"); string fileName = Path.Combine(container.Path, "save0001.sav"); FileStream saveFile = File.Open(fileName, FileMode.Create); XmlSerializer xmlSerializer = new XmlSerializer(typeof(GameData)); XmlSerializer.Serialize(saveFile, gameData); saveFile.Close(); log.Add("Game data saved!"); }

XNA 3.0初步——突现GameServices

clock 十月 17, 2010 20:20 by author alex
1.8实现GameServices 问题 如在教程1-6中解释的那样,你可以将代码放在可复用的GameComponent类中,这些组件可以是相机、粒子系统,用户输入,billboard等。 使用GameComponent类的一个最主要的好处是你可以很容易在它们之间切换,例如相机模式。要把第一人称相机模式变到四元数相机模式只需在Game类的Initialize方法中包含一条代码即可。 但你必须保证在切换组件时不会改变其余代码(这些代码都要使用相机)。 解决方案 你可以让相机组件拥有同一个接口,例如(自定义)ICameraInterface接口。当初始化相机组件时,你需要让Game类知道从现在开始Game中要包含一个实现ICameraInterface接口的组件。用XNA的话说就是组件将自己注册为一个ICameraInterface类型的GameService。 一旦做完上步,代码的其他部分只需简单地向Game类请求当前的ICameraInterface服务,Game类会返回提供当前ICameraInterface服务的相机。这意味着你的代码无需知道现在的相机模式究竟是第一人称相机模式还是四元数相机模式。 工作原理 接口(interface)用来定义一种程序的协定,一个接口包含功能(就是方法)的列表,当你的类实现一个接口后,就必须实现接口中所定义的方法。 下面的代码是如何定义一个ICameraInterface接口: interface ICameraInterface { Vector3 Position { get;} Vector3 Forward { get;} Vector3 UpVector { get;} Matrix ViewMatrix { get;} Matrix ProjectionMatrix { get;} } 所有实现ICameraInterface接口的类必须实现这五个getter方法。无论是第一人称相机还是四元数相机,只要是实现这个接口,那么主程序就一定能访问这五个字段,对于其余代码,知道当前相机是第一人称模式还是四元数模式并不重要,唯一重要的是相机能产生View和Projection矩阵,或许还有一些其他的方向向量。所以你应该为Game类实现一个拥有ICameraInterface 的相机。 让GameComponent实现接口 本例中有两个相机组件,因为你不在屏幕上绘制东西,所以没必要使用DrawableGameComponent,所以相机组件是从GameComponent类继承的。你还要在组件上实现接口: class QuakeCameraGC : GameComponent, ICameraInterface { . . . } 注意虽然一个类只能从一个基类继承(本例中是GameComponent类),但它可以实现多个接口。 下一步你必须确定在类中实现接口中定义的方法。在教程2-3和2-4的QuakeCamera类和Quaternion类中已经实现了。 注册ICameraInterface Service 在一个时间你必须有一个而且只能有一个相机类提供ICameraInterface服务。当你激活一个相机组件,必须要让Game类知道这个相机组件是当前的ICameraInterface,这样才能在其他代码请求当前ICameraInterface的提供者时让Game类返回这个相机。 你可以通过将这个相机注册为Game类的Services 集合的一个GameService实现这一步: public QuakeCameraGC(Game game) : base(game) { game.Services.AddService(typeof(ICameraInterface), this); } 你将这个对象(新创建的第一人称相机组件)添加到接口集合中并表明提供ICameraInterface 服务。 使用方法 当你想使用相机时(例如获取View和Projection矩阵),你应要求Game类返回当前的ICameraInterface。在返回的对象中,你可以获取所有在ICameraInterface中定义的字段。 protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0); ICameraInterface camera; camera = (ICameraInterface)Services.GetService(typeof(ICameraInterface)); cCross.Draw(camera.ViewMatrix, camera.ProjectionMatrix); base.Draw(gameTime); } 在Draw方法中的代码不知道是第一人称相机还是四元数相机,只知道在ICameraInterface中定义的方法。这种做法的优点是你可以很容易的切换相机模式,因为其他代码并不知道这个变化。 注意在Game类的Initialize方法中你还定义了一个camera作为全局变量存储了当前的ICameraInterface的链接。 使用多个GameComponents 对于保证不同GameComponent类的互操作性来说,GameServices是很有用的。本例中你有一个提供IcameraInterface服务的相机组件,那么其他组件类就可以通过查询IcameraInterface服务的方式获取这个相机。 这意味着你无需提供不同组件间的生硬的链接,例如前面的教程中的丑陋的Update方法。本例中你创建一个提供IcameraInterface服务的相机组件,就可以从任意其他组件访问到这个服务,例如从billboard组件的Initialize方法中: public override void Initialize() { device = Game.GraphicsDevice; camera = (ICameraInterface)Game.Services.GetService(typeof(ICameraInterface)); base.Initialize(); } 接下来,billboard组件的Update和Draw方法就可以访问到相机的字段了。 Game类需要初始化相机和billboard组件。相机组件会将自己订阅到IcameraInterface服务,允许billboard组件访问相机。 相机会自动更新,之后billboard组件也会自动更新,billboard组件可以通过IcameraInterface服务访问相机,然后绘制自己。整个处理过程需要在Game类中添加两行代码,你可以轻松地在相机的两种模式间切换。 改变GameComponent的更新顺序 本例中你的billboard组件需要相机组件的输出,所以你应保证相机在billboard之前更新。可以在Game类中将组件添加到Components 集合之前进行此步: public Game1() { graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; //GameComponent camComponent = new QuakeCameraGC(this); GameComponent camComponent = new QuatCamGC(this); GameComponent billComponent = new BillboardGC(this); camComponent.UpdateOrder = 0; billComponent.UpdateOrder = 1; Components.Add(camComponent); Components.Add(billComponent); } 首先你创建了相机和billboard组件,在将它们添加到Components集合前,你设置了它们的更新顺序,小的数字表示先更新。 使用GameComponent和GameServices切换相机模式只需改变程序中的一行代码。在前面的代码中, quaternion模式处在激活状态。如果你想切换到Quake模式,只需去掉QuakeCamera GameComponent类前的注释,并在QuatCam GameComponent类前加上注释。其余代码无需改动,因为需要的只是相机组件提供的IcameraInterface服务。 代码 前面显示的Game1构造函数代码就是获取相机并使billboard工作的所有代码,Initialize, (Un)LoadContent,Update和Draw方法为空。当相机组件创建后,它将自己注册到IcameraInterface服务: public QuakeCameraGC(Game game) : base(game) { game.Services.AddService(typeof(ICameraInterface), this); } 无论何时其他代码需要使用相机,你只需要求Game类返回ICameraInterface服务即可: ICameraInterface camera; camera = (ICameraInterface)Services.GetService(typeof(ICameraInterface)); cCross.Draw(camera.ViewMatrix, camera.ProjectionMatrix);

XNA 3.0初步——使用GameComponents

clock 十月 17, 2010 20:10 by author alex
1.7 使用GameComponents 问题 你想将应用程序分离为GameComponent 类,实现组件的复用。 解决方案 程序的特定部分可以从程序中分离出来。在一个XNA程序中,大多数此种类型的部分都需要被更新和绘制,例如粒子系统和billboard(通常翻译为公告板或广告牌)系统(见教程3-11和3-12)。 正确的做法是为这些特定部分创建一个单独的类。在XNA Game 类中,创建这个类的实例,对它进行初始化,更新和绘制。所以你想让你的新类拥有自己的Initialize, (Un) LoadContent,Update和Draw方法,这让你就可以很容易地在Game类中调用它们了。 如果你想在这个新类中定义这些方法,最好的方法就是从GameComponent类继承。你可以将这个类添加到Game类的Components集合中,这样当Game类初始化完成后就会调用GameComponent类的Initialize方法(如果你已经定义了一个)。 而且,每次Game类的Update方法调用后,GameComponent的Update方法也会自动被调用。 如果你的组件还有绘制某些东西,你可以从DrawableGameComponent类继承而不是从GameComponent类,这样在组件类中还会包含一个Draw method,它会在Game类的Draw方法后被调用。 注意在Game类的Initialize方法最后调用了base. Initialize,就是这个代码行调用了所有GameComponent类的Initialize方法,在其他方法中你也能看到类似的base.Update(gameTime),base.Draw(gameTime)。 工作原理 例如教程3-11中的billboarding代码封装在了一个GameComponent中。而且因为这个代码还需要绘制一些东西,所以使用的是DrawableGameComponent类。 创建一个新的 (Drawable)GameComponent 通过右击项目并选择Add →New File可以添加一个新的类文件,接着选择Class,我把它命名为BillboardGC。在这个新文件中添加XNA using代码行,只需将Game类的using复制到这个新类即可。 下一步保证这个新类从GameComponent类或DrawableGameComponent类继承,如下面的代码所示。代码显示了如何创建教程3-11的billboarding。其中有些代码如CreateBBVertices 并没有写出,因为这个教程我们关注的是Initialize,LoadContent,Update和Draw方法。 class BillboardGC : DrawableGameComponent { private GraphicsDevice device; private BasicEffect basicEffect; private Texture2D myTexture; private VertexPositionTexture[] billboardVertices; private VertexDeclaration myVertexDeclaration; private List<Vector4> billboardList = new List<Vector4>(); public Vector3 camPosition; public Vector3 camForward; public Matrix viewMatrix; public Matrix projectionMatrix; public BillboardGC(Game game) : base(game) { } public override void Initialize() { device = Game.GraphicsDevice; base.Initialize(); } protected override void LoadContent() { basicEffect = new BasicEffect(device, null); myTexture = Game.Content.Load<Texture2D>("billboardtexture"); AddBillboards(); myVertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); } public override void Update(GameTime gameTime) { CreateBBVertices(); base.Update(gameTime); } . . . public override void Draw(GameTime gameTime) { //draw billboards . . . } } 注意在Initialize方法中,你的component可以获取Game类,这样component就能获取Game类的公共字段,例如Game. GraphicsDevice和Game.Content。 使用GameComponent 定义完GameComponent类后,你就可以将它添加到Game类的GameComponent集合中,一旦添加后,GameComponent的注意方法就会自动被调用。最简单的方法就是创建一个GameComponent的实例并立即添加到Components集合中,最好的位置是在Game类的构造函数中。 public Game1() { graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; Components.Add(new BillboardGC(this)); } 这样一开始就能调用Initialize和LoadContent方法,并且在Game类的Update和Draw方法完成后调用这个新类的Update和Draw方法。有些情况下,你需要更新一些组件的公有变量。例如在billboarding组件中你需要更新camPosition和camForward变量调整View和Projection矩阵使之能正确地绘制到屏幕。因此,你应在Game类中添加一个组件变量。 BillboardGC billboardGC; 然后存储这个组件并将它添加到Components集合中: public Game1() { graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; billboardGC = new BillboardGC(this); Components.Add(billboardGC); } 在Game类的Update方法中,你可以更新组件中的四个变量,在Update方法最后调用所有组件的Update方法,让billboarding组件进行更新: protected override void Update(GameTime gameTime) { . . . billboardGC.camForward = quatCam.Forward; billboardGC.camPosition = quatCam.Position; billboardGC.viewMatrix = quatCam.ViewMatrix; billboardGC.projectionMatrix = quatCam.ProjectionMatrix; base.Update(gameTime); } Game类的Draw方法更简单,只是在调用所有组件的Draw方法前清除背景: protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer,Color.CornflowerBlue, 1, 0); base.Draw(gameTime); } 最后一行代码会调用billboarding 组件的Draw方法,将billboards绘制到屏幕。 代码 GameComponent和Game类的Initialize,LoadContent,Update和Draw方法的代码已经在前面写过了。

黄金法则和技巧

clock 十月 17, 2010 17:11 by author alex
黄金法则和技巧 以下是这本书提到的一些“黄金法则”和诀窍。 诀窍 你需要在Xbox 360上有一个Xbox 360 Live帐户,当年开发和测试您的XNA游戏是它必须一直在线。 请确保您的PC和Xbox 360都在同一网络中并可以可以“看到”对方。您可以从电脑上pingXbox 360的IP或将Xbox 360游戏机作为媒体中心连接到您的PC机上。 当您在XNA Game Lanucher的设置中创建一个加密密钥但PC不接受时,可能是因为你输入错误,或它包含0(数字零)和O字母,看起来几乎相同,只是再次尝试即可。您可以随时创建一个新的加密密钥。细节可见第1章。 如果在编译本书的代码时遇到错误,请取保你拥有所有必须的内容,所有类和使用的变量被正确定义。 阅读编译器的错误信息并改变相应的代码。也许您正试图使用一些过时的方法,要么用新方法代替,要么只是简单得把它们注释掉。 如果编译正常,但程序崩溃或抛出一个错误,最有可能是丢失了一些内容文件或您的图形硬件不支持,至少Shader Model 1.1。 重要的是要记下您的构思,并制定一个小概念。只有一页的构思是非常有用的。 要解决问题你使用自上而下的方法,并将单元测试保持在刚才的最高水平上而不用考虑具体的实现。这样,您就可以迅速通过测试,并写入单元测试,使其更容易地看出你必须实现的方法。 对管理项目的最后提示:思考你遇到的问题,并分解为更小的便于管理的部分。 首先编写测试而不要考虑执行情况。只写下你心目中最终代码的模样。 让测试处理尽可能多的情况。例如,TestIsInList方法能同时测试成功调用IsInList和未能成功调用IsInList。花些时间在您的单元测试上,但不要超过50%。你不应该对只有两行代码大方法编写30个测试。 一开始就不断进行测试,即使你认为没有意义。这将迫使你看到必须做什么和执行过程的进度。第一次测试可能无法编译,因为你没有实现任何东西。在执行空的测试方法会失败,因为你没有做什么还没有,以后当一切开始工作你就会感觉好多了。 虽然你不会经常测试您的静态单元测试,但每次当您编译代码时(如果它们运行得足够快)动态单元测试仍可以检测。总保持每天一次或每周一次运行所有的单元测试以确保新的代码更改没有增添新的漏洞或错误。 黄金法则 如无必要不要使用任何外部的DLL-虽然支持使用XNA游戏库DLL,但大多数W indows平台上的DLL会调用系统上Xbox 360不可用的DLL,使用P-Invoke调用非托管的DLL,出于安全原因这在Xbox 360上是绝对不支持的。也不要使用unsafe代码或试图调用外部代码,接入设备或使用不支持的功能,这只能是浪费时间。 不要等到最后才测试 -在编译为Xbox 360游戏项目时,经常会遇到你习惯的方法在Xbox 360上不存在。例如,在Xbox 360中的System.dll没有TryParse方法,一些方法和类(如MouseState类)在Xbox 360也不存在。 不要直接载入内容 -在Windows平台上可以通过Texture2D或CompiledEffect构造函数直接加载纹理或shader。这些方法在Xbox 360上不存在,你只能从内容管道中加载内容。如果想动态加载纹理或shader,那么只会在Windows平台才能实现。在这种情况下应使用XBOX360定义排除此代码部分,使该项目仍然在Xbox 360平台上正常编译。 保存游戏和加载其他内容 -对于保存的游戏你可以使用Content命名空间和许多辅助类。要加载内容你应在StorageContainer.TitleLocation帮助下确保使用正确的路径,它在Windows平台上也能正常工作。在Xbox 360平台上从其他地方加载会出现拒绝访问的IO操作错误。 测试分辨率 -Xbox 360支持许多不同的分辨率,您应该测试至少2-3个不同的分辨率以确保游戏运行很好。 允许使用手柄控制一切 -我看到的大多数XNA游戏犯了只支持键盘输入或手柄输入的错误,这意味着它也只能运行在Windows平台或您必须有一个Xbox 360手柄。应该始终支持你能想到的尽可能多的输入设备。 Xbox 360上的调试和单元测试 -调试和单元测试与在Windows平台并没有什么不同。也许追踪错误和日志消息有点难,但单步调试代码和测试单元测试工作的很好。顺便说一句:编辑并继续只能在Windows平台上实现,Xbox 360游戏不能这样做,这挺烦人的,但通常你是在Windows平台上开发主要部分,所以影响不大。

调整和改编赛车游戏——总结

clock 十月 17, 2010 17:08 by author alex
总结 漫长的一章和漫长的一本书。但考虑到这是我写的第一本书,我还是希望你能喜欢它,并从游戏示例中学到东西。我很喜欢本书的游戏示例,我认为编写游戏的实践方法能帮助初学者,对于高级读者可以跳过简单的部分,并看看游戏编程世界中的更复杂的东西。 这本书几乎涵盖所有学习XNA和游戏的知识,但每个专题分布在不同章节中。例如,物理学一章对于一些普通物理计算也是有用的,但把它与赛车游戏联系在一起能更容易地思考问题。即使你想使用其他物理计算开发一个完全不同的游戏,您也会从中受益,你测试物理引擎时可能会形成一些解决物理问题早期构思。 祝贺你读完了这本书,但你不应该停下来。希望您急于开始编写自己的图形引擎项目或以本书的一个游戏为基础编写自己的游戏。 以下是本书所有议题的一个简单列表: 在第1章之后只介绍了XNA框架和第一个简单的游戏项目,您在第2章创建了一个Pong游戏。在这里您学习了正确使用单元测试、规划整个项目和并写下一个好的游戏构思的基本知识。在技术方面你学会了如何使用精灵,处理输入,播放声音,并做了一些基本的二维碰撞检测。这个游戏可以在同一个屏幕上处理多个玩家。 第3章中,讨论了几个非常重要的辅助类,但只有辅助类还不能制作出一个有趣的游戏,所以本章有创建了一个新:Breakout,它使用了Pong的一些游戏逻辑,但可以挑战自己而不是Pong游戏中的其他选手或愚蠢的人工智能。 第4章讨论了游戏组件,并介绍了如何制作经典游戏:俄罗斯方块。这里制作了一些游戏组件并一直用到了本书最后,但使用游戏组件做一切事情的常规思路并没有没此书的任何游戏采用,这没有任何意义。 第二部分(5-8章)专注于为后面的游戏项目开发一个基本的图形引擎。第5章介绍了如何使模型,为3D代码、材质、字体和线条进行单元测试,最后还在Xbox 360游戏机上做了一些测试。章6至8关注与shader,一个大而复杂的问题,但每章你都会更深入了解shader。第8章以Rocket Commander游戏结束,借助于开发的图形引擎现在已经移植到XNA框架。 第三部分(9-11章)是有关改善游戏引擎的知识。你学会了如何正确使用XACT,如何处理所有的游戏屏幕类、游戏引擎、用户界面和用户输入类。为了让事情更有趣,介绍了XNA Shooter游戏,它看起来真的很棒,玩起来也很有趣。一些较先进的议题如阴影映射也首次被提及,但这些难的东西留到了第四部分。 最后,第四部分(12-14章)的重点是赛车游戏,复杂的地貌和赛道生成代码和游戏中使用的物理引擎。您也学习了游戏屏幕和游戏逻辑的知识以及如何进行测试和调整如赛车游戏一样的较大的游戏项目。最后编写了Speedy Racer改编版本以及如何在现有游戏引擎的基础上创建自己的改编版本。 本章还讨论了很多关于游戏开发进程的最后阶段的东西,但不幸的是大部分游戏项目在发表alpha版本前就已经取消了。许多初学者在遇到一些无法解决的问题时放弃了游戏开发。希望这本书可以给他们一个参考,帮助他们知道编写游戏整个过程,或至少重用一些现有的游戏引擎,而不是重新发明轮子并太早放弃。 也许将来本书还会有后继版本,我也将不断更新我的博客,如果你想阅读更多的内容可去我的博客看看。我也总是乐于接受来自于新的游戏程序员的电子邮件,看到以我的游戏引擎为基础开发的新游戏项目或更多的改编游戏。

调整和改编赛车游戏——挑战:编写自己的改编游戏

clock 十月 17, 2010 17:05 by author alex
挑战:编写自己的改编游戏 本书的最后一个挑战是创建自己的赛车游戏改编版本。您可以实现自己的游戏构思,也可以跟随下面Speedy Racer编写代码的过程。 正如你在本章一开始就看到的,Rocket Commander有相当多的改编版本。仅2006年一年就有超过12个项目,也许还有更多,但相有关人士可能羞于分享他们的游戏或他们不想让别人知道他们使用了Rocket Commander引擎。当然,也有可能是我不知道还有更多的游戏改编版本,它们可能在互联网上某处。 Racing Game和XNA Shooter也有很大的改编潜力。不仅是因为这些游戏不像Rocket Commander那么针对特定情况,而且他们拥有更多可重用的引擎,从而无需从零开始就能够快速地创建新游戏,即使一些使用的技术在新游戏中并不真正需要。 如果你想创建自己的改编游戏或你已经有了新的构思,你就不需要阅读Speedy Race,直接尝试实现自己的第一个游戏吧。在这一章中你学到了足够的知识可以进入到特定的类并根据需要改变它们,使您的新游戏构思得以实施。 示例游戏:Speedy Racer 这里Speedy Racer游戏并没有完全展示出来,创建这样一个游戏对你来说仍是一个挑战,但我会详细讲解。如果你很懒,不想制作Speedy Racer这个游戏,你也可以跳过此步从随书光盘上安装游戏,但我建议你在看实际代码前先试着修改原有的赛车游戏。 第1步:改变汽车模型 由于改编游戏快速创建,而我也请不起一个3D建模师帮我制作一个漂亮的新车,我到互联网去搜索,发现了几个很好的车辆模型,但其中大多数有太多多边形或是陌生的格式,我没发转换文件格式。后来我我放弃了,只使用了一个以前的旧3D模型。图14-21显示了这个赛车模型,约有2.1万个多边形(原始赛车游戏中的赛车模型有约25000个),但没有材质数据。我只增加了一些红色和黑色的材质,但这样做模型已是足够好了,后来我还添加了法线贴图和纹理,使其外观变得更加好。如果你有时间,尽量做得比我好一点。 图 14-21 第2步:菜单的变化 在Rocket Commander中改变菜单往往涉及到大量的纹理和额外的工作,赛车游戏也一样。改变菜单的确需要花些时间,但只需改变背景纹理和色彩,代码可以保持不变。一些菜单上的逻辑甚至还可以简化一点,以避免重新创建某些纹理。 如果你想添加更多的游戏屏幕,在主菜单添加更多的按钮或处理更多的选项,那么再次阅读本章的游戏屏幕部分会对你帮助很大,你可以找出在哪里插入新代码是最好的。 第3步:新的场景和物体 Speedy Racer使用了相同的场景,但我稍微改变了一点场景的着色使游戏的色彩更加丰富。阴影映射和post-screen着色也发生了变化,使游戏看起来有点不同。可参见第8章了解更多post-screen着色的细节以及如何改变它们,尤其是PostScreenMenu.fx和PostScreenGlow.fx中的色彩校正着色。 下面的代码来自于PostScreenGlow shader。您应该能很容易地改变视觉效果和颜色的代码。一个良好的尖端着色始终是发疯,然后解决更体面的价值观。例如,如果您想测试让游戏呈现偏蓝的色调,你可以添加25%甚至50%的蓝颜色值,并查看结果。也许蓝色看起来不是很酷或你还要疯狂地将蓝色设为10%(这很大),你也可以试着调整一下对比度。Speedy Racer的主要目标不是让其变得更真实,只是与原始赛车游戏看起来不同而已。 float4 PS_ComposeFinalImage20( VB_OutputPos3TexCoords In, uniform sampler2D sceneSampler, uniform sampler2D blurredSceneSampler) : COLOR { float4 orig = tex2D(sceneSampler, In.texCoord[0]); float4 blur = tex2D(blurredSceneSampler, In.texCoord[1]); float4 screenBorderFadeout = tex2D(screenBorderFadeoutMapSampler, In.texCoord[2]); float4 ret = 0.75f*orig + GlowIntensity*blur+ HighlightIntensity*blur.a; ret.rgb *= screenBorderFadeout; // Change colors a bit, sub 20% red and add 25% blue (photoshop values) // Here the values are -4% and +5% ret.rgb = float3( ret.r+0.054f/2, ret.g-0.021f/2, ret.b-0.035f/2); // Change brightness -5% and contrast +10% ret.rgb = ret.rgb * 0.975f; ret.rgb = (ret.rgb - float3(0.5, 0.5, 0.5)) * 1.05f + float3(0.5, 0.5, 0.5); return ret; } // PS_ComposeFinalImage20(...) 第4步:开得更快 为了使赛车运动得更快但仍保证游戏可玩,你需要对游戏逻辑做出两个主要的改动: 使道路更宽,这样你能更快地通过,还要调整了直线道路的向上向量使车能更容易的转弯。 改变道路宽度的代码可在Track命名空间的TrackVertex类中找到,只需改变RoadWidthScale的值,在测试中可设置为50(很疯狂),然后再设置为一些合理的值。 /// <summary> /// Minimun, maximum and default road width for our track. /// </summary> public const float MinRoadWidth = 0.25f, DefaultRoadWidth = 1.0f, MaxRoadWidth = 2.0f, RoadWidthScale = 13.25f; // [The constants are then used to generate the left and right track // vertices ...] /// <summary> /// Left side track vertex generation, used for the GuardRail class. /// </summary> /// <returns>Track vertex</returns> public TrackVertex LeftTrackVertex { get { return new TrackVertex( pos - RoadWidthScale * roadWidth * right / 2, right, up, dir, new Vector2(uv.X, 0), roadWidth); } // get } // LeftTrackVertex // [etc.] 使汽车开得更快可直接在CarPhysics类中改变下面的代码。不断使用单元测试去测试新的值。 #region Constants /// <summary> /// Car is 1000 kg /// </summary> const float CarMass = 1000;//1000.0f; /// <summary> /// Gravity on earth is 9.81 m/s^2 /// </summary> const float Gravity = 9.81f; /// <summary> /// Max speed of our car is 275 mph. /// While we use mph for the display, we calculate internally with /// meters per sec since meter is the unit we use for everthing in the /// game. And it is a much nicer unit than miles or feet. /// </summary> public const float MaxSpeed = 275.0f * MphToMeterPerSec; /// <summary> /// Max. acceleration in m/s^2 we can do per second. /// We have also to define the max and min overall acceleration we can /// do with our car (very car specfic, but for this game always the same /// to make it fair). Driving backwards is slower than driving forward. /// </summary> const float MaxAccelerationPerSec = 5.0f,//120.0f,//1000.0f, MaxAcceleration = 10.0f,//70.0f,//250.0f, MinAcceleration = -6.0f;//-35.0f;//250.0f;//-40.0f; /// <summary> /// Friction we have on the road each second. If we are driving slow, /// this slows us down quickly. If we drive really fast, this does not /// matter so much anymore. The main slowdown will be the air friction. /// </summary> const float CarFrictionOnRoad = 17.523456789f;//15.0f;//2.5f; /// <summary> /// Air friction we get if we drive fast, this slows us down very fast /// if we drive fast. It makes it also much harder to drive faster if /// we already drive at a very fast speed. For slow speeds the air /// friction does not matter so much. This could be extended to include /// wind and then even at low speeds the air friction would slow us /// down or even influence our movement. Maybe in a game mod sometime. /// </summary> const float AirFrictionPerSpeed = 0.66f;//0.225f;//0.33f;//0.42f; /// <summary> /// Max air friction, this way we can have a good air friction for low /// speeds but we are not limited to 190-210mph, but can drive faster. /// </summary> const float MaxAirFriction = AirFrictionPerSpeed * 200.0f; /// <summary> /// Break slowdown per second, 1.0 means we need 1 second to do a full /// break. Slowdown is also limited by max. 100 per sec! /// Note: This would not make sense in a real world simulation because /// stopping the car usually needs more time and is highly dependant /// on the speed resulting in longer stopping distances. For this game /// it is easier and more fun to just always brake the same. /// </summary> const float BrakeSlowdown = 1.0f; // [etc.] 第5步:添加道具和新声音 这部分工作量最大。为了使游戏更有趣我添加了一些道具(更快的速度,更宽的道路等等)和新的声音。查阅代码可以了解新道具的更多信息。改变代码也要做一点工作,但没有像创建道具和测试它们那么多。

调整和改编赛车游戏——更多的想法

clock 十月 17, 2010 17:01 by author alex
更多的想法 游戏现在已经运行得很好了,如果你已通过最终的测试它就可以发布了。但即使您可能还没有全部完成,你也可能要尝试更多的想法或思考未来的扩展。我发现自己经常重用现有的引擎来测试新的游戏构思。使用您已经熟悉的现有引擎比从头开始更容易。 本节的内容是关于我在游戏开发时甚至在开发前的一些额外构思。 更多汽车 有更多的车辆模型是我的愿望之一,但由于我不是一个模型设计师,也没有我认识的模型设计师有很多时间为我的小赛车游戏创建一些车辆模型,我只有几个专为赛车游戏制作的三维汽车模型。通过改变汽车的纹理改变汽车的外观相对容易,starter kit中有三个不同的汽车纹理,还有一些代码能动态地改变汽车的颜色,但它们都使用相同的基本几何体。 本章最后展示的改编版本Speedy Racer Game,有另一个汽车模型(从互联网上下载的免费版本),但有不同类型的汽车将使游戏更有趣,尤其是每辆汽车都有不同的速度,加速度,刹车等参数。对于多人游戏,有不同的汽车会更有趣,使每个玩家都可以选择自己最喜欢的汽车类型。 但是,在第12章中你看到商业赛车游戏有很多汽车会耗费了大量的金钱来开发,同时也要花很长时间让这些车表现优秀。希望社会中能找到办法导入新的车型,一段时间后会有足够多的游戏改编版本,从这些改编版本中找到汽车模型去制作更好的改编版本。 渲染汽车的非常具体,例如,Model类中使用专有的RenderCar方法只用来渲染汽车。如果你有一个以上的车型将很难更新这个方法,或许实现通用的Render方法会更好。 在线排行榜 实现对Web服务的调用并从那获得前10名在线排行榜可能问题不大。你可以在Highscore类中添加这些代码,你可以重用Rocket Commander的大多数现有代码。 在您可以使用网络服务前您必须添加System.Web.Services引用,这只适用于Windows平台。你要添加到Highscore类的所有网络服务代码必须在Xbox 360平台上被禁用。 Highscore[] onlineHighscores = new Highscore[10]; Thread onlineGetHighscoreThread = null; /// <summary> /// Get online highscores /// </summary> /// <param name="onlyThisHour">Only this hour</param> private void GetOnlineHighscores(bool onlyThisHour) { // Clear all online highscores and wait for a new update! for (int num = 0; num < onlineHighscores.Length; num++) { onlineHighscores[num].name = "-"; onlineHighscores[num].level = ""; onlineHighscores[num].points = 0; } // for (num) // Stop any old threads if (onlineGetHighscoreThread != null) onlineGetHighscoreThread.Abort(); // Ask web service for highscore list! Do this asyncronly, // it could take a while and we don't want to wait for it to complete. onlineGetHighscoreThread = new Thread(new ThreadStart( // Anoymous delegates, isn't .NET 2.0 great? ^^ delegate { // See notes above try { string ret = new www.xnaracinggame.com. RacingGameService().GetTop10Highscores(onlyThisHour); // Now split this up and build the online highscore with it. string[] allHighscores = ret.Split(new char[] { ',' }); for (int num = 0; num < allHighscores.Length && num < onlineHighscores.Length; num++) { string[] oneHighscore = allHighscores[num].Split(new char[] { ':' }); onlineHighscores[num] = new Highscore( oneHighscore[0].Trim(), oneHighscore[2], Convert.ToInt32(oneHighscore[1])); } // for (num) } // try catch { } // ignore any exceptions! })); onlineGetHighscoreThread.Start(); } // GetOnlineHighscores(onlyThisHour) 为了此代码正常工作您需要在RacingGameService类的指定位置运行一个网络服务编写网络服务本身不难,但实现一个漂亮的网站将数据显示给访问者要做大量的工作。也许这项工作会在以后进行。 更多Shader和特效 在游戏中包含更多的shader和特效始终是一件好事,至少从玩家的角度看,因为游戏看上去更酷,与几年前的游戏有更多不同,那时许多游戏采用类似的渲染技术,看上去都很类似。 你可以很容易插入的一个shader是来自与Rocket Commander的视差映射,但要使用视差映射代替标准的法线映射,你需要在材质上应用高程图。 但是,添加类似于XNA Shooeter游戏的特效和特效系统也很不错(见图14-19)。您目前没有特效用于汽车爆炸或撞上东西。 图 14-19 欲了解更多post screen shader的例子可回到第8章看一下。赛车游戏将极大地受益于HDR渲染,特别是如果你改变光照情况并驱车通过隧道时。 第8章讨论过的每像素运动模糊效果也比目前的全屏post screen 效果更好,但显然也难得多。 我一直在改进赛车游戏改编版本过程中做的主要一件事就是改变PostScreenMenu和PostScreenGlow类的颜色校正因素和基本着色代码。无需改变所有的纹理就能使整场游戏有一个截然不同的表现。 更多的游戏模式 更多的游戏模式也很有趣。我不是一个真正的赛车游戏专家,但实现一个或多个通用的游戏模式也许是有趣的,您能经常在商业赛车游戏中发现不同的游戏模式: 挑战最好成绩:这是这个游戏目前的模式。它也可以扩展到更多的玩家,你需要添加一些人工智能赛车作为对手。 争先模式:对于这个游戏模式你显然需要一辆以上的汽车和一个以上的驾驶员。这是大多数赛车游戏的主要游戏模式。目前这是不可能的,因为你没有多人模式,也没有代码处理其他车辆。即使有一些人工智能代码您仍需改进车辆的碰撞代码,因为现在你会和赛道上的其他车辆发生碰撞。 实践模式:一些赛车游戏的特殊模式,在进入竞争前让您熟悉赛道。您可以训练技能和试驾赛道的某些部分,但这个游戏模式只有在你在竞争模式中失败是才有意义,这并不是游戏的主要部分。 时间轨迹:您必须在规定时限内完成赛道或某些地区。这对于初学者来说更难,因为比起只是完成赛道和挑战最高分,挑战时间难得多。 绕障碍模式或驾驶课模式:你必须在规定时限绕过放置在路上的路障并尽量避免撞翻它们。还可能是在道路的规定一侧或按道路指示行驶。 技巧模式:这是一个更受欢迎的游戏模式,特别是在街道赛车游戏中。玩技巧意味着你要漂移你的车,通过曲线下滑,甚至跳过赛道的某些部分。如赛道狂飙之类的游戏中你必须完成其他种类的技巧。您要完成环形轨道,但赛道狂飙中大多数高级模式部分会使它更加难以完成。 碰撞模式:有些游戏把重点放在撞毁汽车和尽可能快的破坏您的车上。乍一听很有趣,但我在进一步深入了解前就放弃了这种游戏。 给汽车添加武器,或者至少添加有特殊功能的道具。许多街机赛车游戏实现了这个想法,有许多不同的游戏模式都来自与这一想法。在多人游戏中这个想法很不错,每个人都互相争斗而道具可以帮助您摧毁你的敌人或把他们推到一边让您领先。这种游戏模式最典型的例子是马里奥赛车,Wacky Wheels、Micro Machine等。 实现自己的构思或将现有的构思混合到赛车游戏中。你甚至可以把赛车部分完全去除并添加一些完全不同的东西。尽情发挥你的想象。 多人模式 如果你和一些朋友在局域网或在互联网上共同进行游戏,那么刚才讨论的游戏模式会变得有趣得多。由于Xbox 360不支持网络代码,多人模式只能工作在PC上。 在实现网络的基本代码后,主要的变化在Player中,要允许这个类的多个实例。您还需要处理玩家列表并在多人消息的帮助下更新每个玩家的数据。仅此一点就可以写一本书,因为多人游戏有许多议题,而且有许多方式能实现这些代码。 作为一个小例子,看一下图14-20,此图显示了一些多人网络类。游戏类保存玩家列表。每个玩家从其他玩家处接收消息并将所有接受到的消息添加在一个内部列表中。而通过Message类和MessageType枚举加载消息。 图 14-20

调整和改编赛车游戏——最后的单元测试和调整

clock 十月 17, 2010 16:54 by author alex
最后的单元测试和调整 现在您拥有了游戏的所有类,但还没完。我们已经谈到了几次Player类,但你从来没有见过它的调用。原因是XNA分隔了更新和渲染代码。如果你看一下RacingGame类的Update方法,你终于可以看到对Player类Update方法的调用: /// <summary> /// Update racing game /// </summary> protected override void Update(GameTime time) { // Update game engine base.Update(time); // Update player and game logic player.Update(); } // Update() 如果你看一下Player类的内容,您可能会问,为什么它是如此简单。Update方法在这里并没有做太多工作,它只是处理一些额外的游戏逻辑。在Rocket Commander游戏中Player类处理了几乎整个游戏的逻辑和输入。借助于分布在四个不同类中但相互联系的游戏逻辑代码,赛车游戏的游戏逻辑是非常简单的(见图14-12)。 图 14-12 BasePlayer - ?这是基本游戏逻辑类,它拥有所有重要的变量和辅助属性,判断游戏是否已经结束,您已经玩了多久,你是否赢得了游戏。这个类的目的是使派生类能有一个简单的方法来访问这些数据,因为游戏结束或游戏还没有开始时,您无法控制汽车。虽然BasePlayer类提供了你需要的几乎所有东西以获知当前的游戏状态,但它并不进行处理。这个类只是更新时钟,其他游戏逻辑是在派生类中处理的! /// <summary> /// Update game logic, called every frame. In Rocket Commander we did /// all the game logic in one big method inside the player class, but it /// was hard to add new game logic and many small things were also in /// the GameAsteroidManager. For this game we split everything up into /// many more classes and every class handles only its own variables. /// For example this class just handles the game time and zoom in time, /// for the car speed and physics just go into the CarController class. /// </summary> public virtual void Update() { // Handle zoomInTime at the beginning of a game if (zoomInTime > 0) { // Handle start traffic light object (red, yellow, green!) RacingGame.Landscape.ReplaceStartLightObject( 2-(int)(zoomInTime/1000)); zoomInTime -= BaseGame.ElapsedTimeThisFrameInMs; if (zoomInTime < 0) zoomInTime = 0; } // if (zoomInTime) // Don't handle any more game logic if game is over or still zooming // in. if (CanControlCar == false) return; // Increase game time currentGameTimeMs += BaseGame.ElapsedTimeThisFrameInMs; } // Update() CarPhysics -这个类已在前一章讨论过了。它继承自BasePlayer类,并添加了车和其他地方的物理计算。Update方法更新诸如车的方向、位置、向上矢量、速度和加速度等内部变量,大多数物理计算工作是在一些如ApplyGravity、ApplyGravityAndCheckForCollisions、SetGroundPlaneAndGuardRails等辅助方法中进行的。使用UpdateCarMatrixAndCamera辅助方法获得汽车的矩阵和更新相机的观察位置。 为了测试物理效果你应使用这个类中的TestCarPhysicsOnPlane和TestCarPhysicsOnPlaneWithGuardRails单元测试,特别是如果你想改变一些在这个类中定义的一些常量,如汽车质量,最大速度,最高转速和加速度。 在第13章中您可以找到更多有关这个类的信息。 ChaseCamera类似于Rocket Commander中的SpaceCamera类或XNA Shooter中的SimpleCamera类。这个类不是很复杂,它继承自CarPhysics类,其中为您提供了几乎所有您所需要的东西。它支持两个相机模式:游戏或菜单中使用的Default模式和主要用于单元测试的FreeCamera模式。 BaseGame类的观察矩阵在这里更新,如果您需要获得摄像机的位置,旋转矩阵或旋转轴,可以看这里。您可能不会频繁地使用这个类,因为游戏的大多数重要信息,如汽车的当前位置或游戏时间能从BasePlayer和CarPhysics类的属性中获得。 最后是Player类,它继承于ChaseCamera类并合并了四个类的所有功能。如果你想添加更多的游戏逻辑或规则,这里可能是最容易的地方,但如果你不改变基类的Update方法,您将无法改变太多游戏的行为。例如,为了改变汽车的最高速度,这是在CarPhysics类中直接处理的,那么在CarPhysics类中改变更容易,但如果你想在到达检查点或完成一圈后添加一个终止条件或文字信息,那么在Player类中添加代码更容易一些。 调整赛道 您现在已经知道如何改变一般的游戏逻辑规则,但大多数的关卡是从level数据直接定义的。正如你在第12章中所见,创建赛道并不容易,将赛道数据导入到游戏中也不简单,因为你需要做以下的事情(见图14-13): 您需要3D Studio Max打开一个赛道并修改它。可能使用其他3D建模程序也行,但尚未测试过。作为一个游戏程序员你可能没有这些工具。 接下来,您必须在3D Studio Max中使用一个COLLADA导出插件,得到一个对应最新版本3D Studio Max的导出插件并不容易。例如,在编写本书时,没有对应3D Studio Max 9的Collada格式导出插件,你不得不使用3D Studio Max 8去导出赛道,而这又不对应储存为3D Studio Max 9格式的.max文件。你可以看到这将变得更加复杂。 现在,你需要在TrackImporter类中单元测试的帮助下将Collada格式的赛道数据导入到游戏中,如果发生错误会告知你,但不会给你可视的反馈。 最后你必须自己测试赛道,或者通过开始并玩游戏,或者通过使用Track和TrackLine的单元测试。 图 14-13 是的,这个不是很理想,我会在以后制作一个赛道编辑器来解决这一问题。请查看官方网站http://www.xnaracinggame.com,获取游戏的更新和修改赛道更好的方法。 现在您可以创建赛道,它们通过定义一些3D点的方式在TrackLine类的单元测试中被使用。要导入一个赛道,你只需在一个3D点数组中写下赛道要用到的所有点,并用这个数组替代导入的赛道的二进制数据。您还可以创建隧道,道路宽度辅助类,并设置场景模型。 TestRenderingTrack单元测试显示了如何使用自定义的向量数组创建赛道并初始化TrackLine类。如果您只想尽快测试一些赛道的构思,我建议首先使用这个单元测试。 /// <summary> /// Test rendering track /// </summary> public static void TestRenderingTrack() { TrackLine testTrack = new TrackLine( new Vector3[] { new Vector3(20, 0, 0), new Vector3(20, 10, 5), new Vector3(20, 20, 10), new Vector3(10, 25, 10), new Vector3(5, 30, 10), new Vector3(-5, 30, 10), new Vector3(-10, 25, 10), new Vector3(-20, 20, 10), new Vector3(-20, 10, 5), new Vector3(-20, 0, 0), new Vector3(-10, 0, 0), new Vector3(-5, 0, 0), new Vector3(7, 0, 3), new Vector3(10, 0, 10), new Vector3(7, 0, 17), new Vector3(0, 0, 20), new Vector3(-7, 0, 17), new Vector3(-10, -2, 10), new Vector3(-7, -4, 3), new Vector3(5, -6, 0), new Vector3(10, -6, 0), }); TestGame.Start( delegate { ShowGroundGrid(); ShowTrackLines(testTrack); ShowUpVectors(testTrack); }); } // TestRenderingTrack() 阴影映射 阴影映射类在本章的前面已经讨论过了,它是调整的主要地方。不仅有许多设置和参数,还包括一些shader,优化必须兼顾性能和视觉质量。 贯穿阴影映射技术整个过程的主要单元测试是ShadowMapShader类中的TestShadowMapping方法(见图14-14)。如果您按下Shift键(或手柄的A键)你可以看到阴影贴图和ShadowMapBlur shader的两个模糊pass。如果您想测试其他三维物体的阴影,您可以用其他任何三维模型替换汽车。 图 14-14 在本章的前面你看到了GameScreen类是如何使用ShadowMapShader类的。首先,你对所用使用阴影映射的物体调用GenerateShadows和RenderShadows方法。请注意,这两种方法绘制三维数据,你应该只在必要时才绘制。这些数据应该可以从虚拟的阴影映射光线中看到,如果对象是如图14-14所示的只接受阴影的平板,你就不需要把它列入到GenerateShadows方法调用。只需使用RenderShadows方法让阴影投射到它上面! if (Input.Keyboard.IsKeyUp(Keys.LeftAlt) && Input.GamePadXPressed == false) { // Generate shadows ShaderEffect.shadowMapping.GenerateShadows( delegate { RacingGame.CarModel.GenerateShadow(Matrix.CreateRotationZ(0.85f)); }); // Render shadows ShaderEffect.shadowMapping.RenderShadows( delegate { RacingGame.CarSelectionPlate.UseShadow(Matrix.CreateScale(1.5f)); RacingGame.CarModel.UseShadow(Matrix.CreateRotationZ(0.85f)); }); } // if 在阴影映射产生后您就可以开始渲染真正的3D内容。单元测试使用RenderShadows委托方法,这和在sky cube shader帮助下绘制背景天空盒的代码类似。通过这种方式可以优化游戏,绘制哪个对象,哪些会产生阴影以及哪些会接受阴影。如果您只是生成、渲染并将阴影提供给场景中的每个物体,帧速率将大大下降。在游戏代码中只有大约10% -20%的可见物体将被列入阴影映射中,但在这个单元测试中你只测试汽车和汽车选择平台的阴影映射的。 if (Input.Keyboard.IsKeyUp(Keys.LeftAlt) && Input.GamePadXPressed == false) { ShaderEffect.shadowMapping.ShowShadows(); } // if 通过单元测试您现在就可以调整阴影映射的代码。大多数调整变量可以在ShadowMapShader类中直接找到,但其中一些如阴影颜色只在ShadowMap.fx着色文件中定义,一旦shader被加载就不会改变。 要查看阴影映射的结果可通过按下Shift键或手柄上的A键。最重要的渲染目标是第一个,它显示了从虚拟阴影光线位置而来的实际阴影贴图。它显示为青色,因为您使用的渲染目标表面格式是本章早些时候讨论过的R32F格式,其中仅包含红色通道。其他颜色通道没用用到,将使用默认值1.0。如果阴影贴图的值是1.0(最不可能的值),你最后将获得白色,如果接近0.0则产生青色。往往很难看到阴影贴图的差异。为了提高数值,您可以将它乘以一个常量或将虚拟阴影光线的位置更靠近目标。 下面的变量和常量是最重要的调整量,您可以通过改变ShadowMap.fx和PostScreenShadowBlur.fx中的顶点和像素shader调整更多的事情。请注意,由于pixel shader1.1的限制,大多数针对shader 1.1的代码是固定的,不会受到大多数变量的影响。如果您仍需支持shader model 1.1并改变某些参数,请确保shader model 1.1仍能然工作。可以通过在shader类中强制使用shader model 1.1技术代替2.0结尾的技术(这是用于shader model 2.0的)来进行测试。 virtualLightDistance和virtualVisibleRange用于构建虚拟阴影光线,而lightViewMatrix,它对阴影贴图的产生和绘制都是非常重要的。虚拟光线距离(virtualLightDistance)是指离开阴影映射观察的位置(即汽车的位置,更精确的说汽车前面一点的位置,这样能更好地匹配玩家相机的观察区域)的距离。虚拟可视范围(virtualVisibleRange)显示阴影映射矩阵的视野。它和游戏中使用的观察矩阵有很大不同,与观察矩阵也没有任何关系。 例如,在XNA Shooter使用一个非常遥远的虚拟光线位置和一个相对较小的虚拟可视范围,导致的结果是阴影映射矩阵在一个很小的视场范围内,几乎正交?。通过这种方式,阴影始终朝向同一方向,但这样会导致难以调整的阴影映射的光线。在赛车游戏你不用考虑太多更近的虚拟光距离,因为驾车时你不太会注意到阴影,而且因为阴影映射中的车位置总是相同,所以车的阴影也始终是相同的。 nearPlane 和 farPlane用来调整阴影贴图的深度计算。如果所有的阴影贴图值有20.0到30.0的深度值,那么在游戏中(例如1.0到500)使用相同的nearPlane和farPlane值是没有意义的,因为阴影贴图只有2%的深度缓冲精度。对于深度缓冲值来说将不是那么重要了,因为你只有当几何体重叠导致深度值重叠时才会遇到问题,而如果场景构造的好的话,这种情况通常不会发生。 另一方面,对于阴影映射,你只需看看重叠的深度值,因为你需要测试场景中的每个像素是否使用阴影映射。使用一个不好的阴影映射深度缓冲精度将使整个阴影映射变得很糟糕。这也是有许多其他可用的阴影算法的主要原因之一,尤其是stencil阴影,它是阴影映射的主要竞争对手。使用stencil阴影会解决很多问题,但它往往难处理得多,而且还会使用更多的几何体,这样会拖慢游戏。 赛车游戏的主要使用farPlane值,这个值很低(30到50),然后在shader代码中自动生成nearPlane。老版本使用nearPlane值,它约是farPlane值的一半超过以提高深度的精度,但靠近虚拟光线的物体会在阴影映射生成过程中被忽略。为了更好地调整nearPlane值可参见XNA Shooter游戏,它为虚拟光线距离和范围值采用了更好的代码。 // Use farPlane/10 for the internal near plane, we don't have any // objects near the light, use this to get much better percision! float internalNearPlane = farPlane / 10; // Linear depth calculation instead of normal depth calculation. Out.depth = float2( (Out.pos.z - internalNearPlane), (farPlane - internalNearPlane)); texelWidth, texelHeight, texOffsetX和texOffsetY值用来告诉shader阴影贴图使用的纹理像素的大小。这些值在CalcShadowMapBiasMatrix辅助方法中计算,这个方法将所有这些值放入texScaleBiasMatrix辅助矩阵中,接着shader使用这个矩阵变换所有阴影映射的位置,以更好地匹配阴影贴图。 /// <summary> /// Calculate the texScaleBiasMatrix for converting proj screen /// coordinates in the -1..1 range to the shadow depth map /// texture coordinates. /// </summary> internal void CalcShadowMapBiasMatrix() { texelWidth = 1.0f / (float)shadowMapTexture.Width; texelHeight = 1.0f / (float)shadowMapTexture.Height; texOffsetX = 0.5f + (0.5f / (float)shadowMapTexture.Width); texOffsetY = 0.5f + (0.5f / (float)shadowMapTexture.Height); texScaleBiasMatrix = new Matrix( 0.5f * texExtraScale, 0.0f, 0.0f, 0.0f, 0.0f, -0.5f * texExtraScale, 0.0f, 0.0f, 0.0f, 0.0f, texExtraScale, 0.0f, texOffsetX, texOffsetY, 0.0f, 1.0f); } // CalcShadowMapBiasMatrix() shader中的 shadowColor常数用来将阴影区域变暗。借助于模糊效果和和PS_UseShadowMap20中使用的PCF3×3(在3×3盒上过滤提高精度),阴影颜色通过周围非阴影区域进行插值。使用完全黑色的阴影(0,0,0,0)往往是最简单的解决办法,因为它修复了许多阴影映射错误,但在明亮的光线下不好看。在这种情况下阴影不是全是黑色,还有周围环境的颜色和灯光。 // Color for shadowed areas, should be black too, but need // some alpha value (e.g. 0.5) for blending the color to black. float4 ShadowColor = { 0.25f, 0.26f, 0.27f, 1.0f }; depthBias 和 shadowMapDepthBias是shader代码中一起调整的两个阴影映射参数。 // Depth bias, controls how much we remove from the depth // to fix depth checking artifacts. For ps_1_1 this should // be a very high value (0.01f), for ps_2_0 it can be very low. float depthBias = 0.0025f; // Substract a very low value from shadow map depth to // move everything a little closer to the camera. // This is done when the shadow map is rendered before any // of the depth checking happens, should be a very small value. float shadowMapDepthBias = -0.0005f; shadowMapDepthBias被添加到阴影贴图生成代码中使深度值更接近与观察者。 // Pixel shader function float4 PS_GenerateShadowMap20(VB_GenerateShadowMap20 In) : COLOR { // Just set the interpolated depth value. float ret = (In.depth.x/In.depth.y) + shadowMapDepthBias; return ret; } // PS_GenerateShadowMap20(.) depthBias值更重要,它用在UseShadowMap20技术的阴影深度比较代码中。没有depthBias大多数阴影映射像素不会被施加阴影,只是有来产生和接收阴影,由于贴图精度的误差导致类似的值往往会pop in或out阴影映射(见图14-15)。请注意,阴影映射模糊效果隐藏了错误,但它们越强,模糊效果越明显,即使有良好的模糊代码应用到阴影映射,在游戏中仍会出错,特别是当移动相机时。 图 14-15 / Advanced pixel shader for shadow depth calculations in ps 2.0. // However this shader looks blocky like PCF3x3 and should be smoothend // out by a good post screen blur filter. This advanced shader does a // good job faking the penumbra and can look very good when adjusted // carefully. float4 PS_UseShadowMap20(VB_UseShadowMap20 In) : COLOR { float depth = (In.depth.x/In.depth.y) - depthBias; float2 shadowTex = (In.shadowTexCoord.xy / In.shadowTexCoord.w) shadowMapTexelSize / 2.0f; float resultDepth = 0; for (int i=0; i<10; i++) resultDepth += depth > tex2D(ShadowMapSampler20, shadowTex+FilterTaps[i]*shadowMapTexelSize) ? 1.0f/10.0f : 0.0f; // Multiply the result by the shadowDistanceFadeoutTexture, which // fades shadows in and out at the max. shadow distances resultDepth *= tex2D(shadowDistanceFadeoutTextureSampler, shadowTex).r; // We can skip this if its too far away anway (else very far away // landscape parts will be darkenend) if (depth > 1) return 0; else // And apply shadow color return lerp(1, ShadowColor, resultDepth); } // PS_UseShadowMap20(VB_UseShadowMap20 In) 关于阴影映射技术可能还有更多的东西可以讨论,也可以使用更好的代码来构建虚拟光线矩阵和调整某些参数使阴影表现得更好。你看到了阴影映射的最重要的代码,但还有更多的技巧。现今还有这么多的阴影映射技术和技巧,可能需要另一本书才能讲完。 今天两个最激动人心的阴影映射技术是perspective shader mapping lighting generation技术(有许多不同的变化,我在一年前写过一些这个技术的代码,但它真的很难调整和优化,尤其是如果您的游戏允许不同的perspective时)。另一个技术是variance shadow mapping,它使用两个阴影贴图替代一个(或两个通道),并允许存储精度更高的值。我没有太多时间研究variance shadow mapping,它是一个相当新的技术,但早期试验表明,通过小得多的阴影映射尺寸(512×512的阴影贴图看起来和传统的2048×2048一样好)你可以极大的提升速度和节省内存带宽,它修正了不少的阴影映射的问题。但是,这种阴影映射总是有问题,一些程序员如著名的id Software的约翰卡马克不太喜欢这个技术,他宁可实现更加复杂的stencil shadow而不是使用阴影映射。 Windows上的最后测试 好了,有了这么多代码,现在可以进行游戏测试了。在您启动游戏并在赛道上驾驶挑战最高分前,您应该确保已经查看了游戏引擎中的大部分单元测试(见图14-16)。 图 14-16 如果您在测试游戏前进行单元测试,你就不必测试阴影映射,物理效果,菜单等直接在游戏中的东西。你要在单元测试中解决所有问题,而不是直到他们工作正常。然后无需测试游戏本身,游戏最终将运行得非常不错,所有你要做的就是进行更小的单元测试。 常常会对游戏进行改进或直接在游戏代码中调整东西。你会在游戏的最后测试阶段调整游戏源代码并修复臭虫,但你应该不要花太多的时间在重启游戏并测试问题上。例如,如果你在某些物理计算中遇到一个错误,或你想调整一些阴影映射的值,你应该使用现有的单元测试或写一个新的单元测试,这比不断地重新启动游戏加载所有的内容和子菜单简单得多。重新启动游戏往往会点击好几次并等待载入和屏幕切换,会拖慢测试过程。 我在最后阶段一直使用的一个技巧是改变游戏屏幕初始化代码。这样,我可以直接进入游戏而不是现进入主菜单,那样的话我还要先设置一些选项,然后选择一个关卡,然后才并开始游戏。如果你只想测试游戏本身中的一些问题,一遍又一遍地进行上述操作是没有意义的。 // Create main menu at our main entry point gameScreens.Push(new MainMenu()); // But start with splash screen, if user clicks or presses Start, // we are back in the main menu. gameScreens.Push(new SplashScreen()); #if DEBUG //tst: Directly start the game and load a certain level, this code is // only used in the debug mode. Remove it when you are done with // testing! If you press Esc you will also quit the game and not // just end up in the menu again. gameScreens.Clear(); gameScreens.Push(new GameScreen()); #endif 因为所有的单元测试只工作在调试模式下,你也无法在release模式中添加NUnitFramework.dll,您应该确保游戏在Release模式下也运行良好。有时在Release模式可以获得更好的性能,但由于大多数性能的关键代码发生在XNA框架内部,如果您的代码优化得足够好的话,两种模式下性能区别不大。 图14-17显示了正在运行的赛车游戏。仍有东西需要调整,但游戏运行得已经很不错了,在Xbox 360的最高分辨率下(1080p,是1920×1050)也具有良好的帧速率。最后的微调,关卡的设计和游戏测试要花额外一周的工作时间,但很有趣。把游戏给一些你认识的人(或许那些人对你的这个游戏类型并不喜欢)试玩是个不错的主意。还要确保游戏安装容易。没有人愿意自己编译游戏并找出哪些组件被使用。安装文件应包含已编译的游戏,并应检查框架是否已经安装在目标计算机上。您的游戏可能需要.NET Framework 2.0 (约30MB),最新的DirectX版本(约50MB),以及XNA Framkwork Redistributables(只有2MB)。在Xbox 360上目前还没有可用的部署方法。我选择NSIS(Nullsoft Install Script)制作安装文件,如果用户没有安装以上这些框架,它能自动下载并安装。之后你就可以享受游戏了。 图 14-17 Xbox 360上的最后测试 显然你也希望你的游戏在Xbox 360上也运行良好,赛车游戏主要是针对Xbox 360平台开发的,让赛车游戏运行在游戏机平台上也是很有意义的,特别是如果你像我一样有一个方向盘控制器。 正如我之前多次提到的,你应在Windows和Xbox 360平台同时进行所有的单元测试,但你可能会不时忘记这样做,然后当项目结束时,例如,在Xbox 360上的最后测试时你会发现阴影映射工作的和在电脑上的不一样好了,现在到了再次使用这些单元测试(见图14-16)的时候了,在Xbox 360上一个接一个进行单元测试直到找出问题所在。 由于赛车游戏是我为Xbox 360开发的第一个项目,我犯了好几个错误(XNA的第一个测试版本还不支持Xbox 360平台,所以我只能在PC上测试XNA)。两个主要的问题一个是游戏和菜单中的布局在某些电视上并不匹配(这本书前面已经谈到过这个问题),另一个问题是Xbox 360上某些特定的渲染目标表现与PC有很大不同。 最难的部分是在Xbox 360上实现 确的阴影映射。有几个解决渲染目标的方法,我也用了一些技巧,但这些技巧不允许也不可能在Xbox 360上使用,包括同时使用几个渲染目标和在渲染目标后重用后备缓冲区的信息。 以下是一些文字来自与2006年11月我的博客(http://abi.exdream.com)上,当时我在访问美国的XNA团队时谈到了这些问题: 重要 今天,我在XNA的Xbox 360版本上花了很多时间。在先前的版本上当我测试Xbox 360的Starter Kit时遇到了很多问题,但大多数解决了。Windows平台和Xbox 360平台上约99%的工作是相同的,但如果你不幸遇到剩下的1% ,仍会让你生气得撞墙。例如在Windows平台上一些较先进的阴影映射工作正常,但在Xbox 360会发生各种疯狂的事情,游戏会崩溃,你看到黑条覆盖在在屏幕上或输出不正确。 如果你像我一样以前并没有与Xbox 360打过交道,我可以告诉你不容易习惯游戏机平台上使用渲染目标的方式。你必须通过XNA(或Xbox 360的SDK)中的一些辅助方法解决这些问题,要将获得的内容复制到您的纹理。而这在windows平台是不需要的。但即使你注意到shader表现的不同,例如,我的大多数post screen shader使用的背景缓冲组合结果,有时混合了好几次。这在windows平台工作良好,与在DirectX中的行为方式相同。 但是,经过与XNA工作组的Tom Miller、Matt和Mitch Walker的讨论并进行了一些测试,很明显,在渲染到渲染目标后,背景缓冲会产生垃圾数据。这对一些shader来说是非常糟糕的,因为它的pass需要2个独立的图像数,然后混合在一起到最后的pass。我使用了后备缓冲储存了其中一个,渲染目标储存另一个,但要正确运行在Xbox 360必须要做出改变。好的是只有一个shader,而在我的游戏引擎中有超过100个shader,重新考虑所有的post screen shader可不是件有趣的事。 图14-18显示了重定位代码使游戏在电视监视器能显示正常。因为你不知道连接的是什么样的监视器,你最终可以显示多大的屏幕区域,但这里使用的值在我测试过的所有系统上看起来都很好。 安全区域(90%可见)显示为红色的边界,但即使你看到100%的画面,游戏看上去仍很好。如果您的电视机比较糟,低于90%的可见区域,你仍然会看到所有的重要信息,但一些文本可能被阻挡。 下面的代码是用来将UI元素移动到更加靠近中间的地方,在PC版本上这些元素更接近屏幕边界: // More distance to the screen borders on the Xbox 360 to fit better // into the safe region. Calculate all rectangles for each platform, // then they will be used the same way on both platforms. #if XBOX360 // Draw all boxes and background stuff Rectangle lapsRect = BaseGame.CalcRectangle1600( 60, 46, LapsGfxRect.Width, LapsGfxRect.Height); ingame.RenderOnScreen(lapsRect, LapsGfxRect, baseUIColor); Rectangle timesRect = BaseGame.CalcRectangle1600( 60, 46, CurrentAndBestGfxRect.Width, CurrentAndBestGfxRect.Height); timesRect.Y = BaseGame.Height-timesRect.Bottom; ingame.RenderOnScreen(timesRect, CurrentAndBestGfxRect, baseUIColor); // [etc.] 图 14-18

调整和改编赛车游戏——游戏屏幕

clock 十月 17, 2010 09:52 by author alex
游戏屏幕 赛车游戏中有很多不同的游戏屏幕,这些都是由RacingGame类中的gameScreens堆栈管理的。本节介绍游戏中使用的大部分屏幕和对应的功能。大多数游戏屏幕相当简单,但其他的有点复杂并实现了一个单元测试,通过单元测试能更好地了解这个类。例如,Credits屏幕是相当简单的,它只显示一个背景纹理,但主菜单很复杂并具有所有能进入另一个屏幕的按钮。Options屏幕介绍了许多新的控制选项,这些都要进行测试,这个类里有一个单元测试能帮你完成这个过程。 所有的游戏屏幕类都继承于IgameScreen接口(见图14-6),您也许还记得在第8章的Rocket Commander中也使用过。几乎没有改变,但更简单了。您现在只有一个叫做Render的方法,它没有参数并返回一个布尔值。该代码和你在XNA Shooter中使用的几乎一样。如果Render方法返回true意味着您可以返回到以前的游戏屏幕,一旦推出最后一个游戏屏幕则游戏推出。通常游戏屏幕返回false,因为玩家再次进入游戏屏幕后不会立即退出。 图 14-6 由于这个简单的接口,所以所有的游戏屏幕的类视图几乎是一样的,它们都只有一个Render方法。有些类还会有一些私有的辅助方法,但它们大多是非常简单的。一些更复杂的游戏屏幕还有单元测试用以测试是否所有内容和功能都能正确实行。处理整个游戏的GameScreen类是复杂的。不像XNA Shooeter或Rocket Commander,所有游戏代码的的处理和渲染是在Landscape和model类中的。游戏逻辑在Player类中被处理,这个有点复杂,但你已经在上一章学习了所有的CarPhysics类的基本物理知识和ChaseCamera类。 看看图14-7了解一下游戏屏幕的基本概况。最复杂的类显然是Mission类,它处理游戏过程和游戏逻辑。它不包含所有的游戏代码,有些是在Player类和RacingGame类中被处理的。 图 14-7 启动屏幕 启动屏幕(见图14-8)是较容易的一个类,它只是等着玩家按下手柄上的Start。如果没有手柄,Space或Esc键或鼠标左键也可让玩家继续。这个类唯一令人感兴趣的地方就是让“Press Start to continue”文字闪烁的代码。 图 14-8 /// <summary> /// Render splash screen /// </summary> public bool Render() { // This starts both menu and in game post screen shader! BaseGame.UI.PostScreenMenuShader.Start(); // Render background and black bar BaseGame.UI.RenderMenuBackground(); BaseGame.UI.RenderBlackBar(352, 61); // Show Press Start to continue. if ((int)(BaseGame.TotalTime / 0.375f) % 3 != 0) BaseGame.UI.Logos.RenderOnScreen( BaseGame.CalcRectangleCenteredWithGivenHeight( 512, 352 + 61 / 2, 26, UIRenderer.PressStartGfxRect), UIRenderer.PressStartGfxRect); // Show logos BaseGame.UI.RenderLogos(); // Clicking or pressing start will go to the menu return Input.MouseLeftButtonJustPressed || Input.KeyboardSpaceJustPressed || Input.KeyboardEscapeJustPressed || Input.GamePadStartPressed; } // Render() RenderMenuBackground方法有点复杂。它用来显示菜单背景,即显示出汽车通过一条赛道。汽车是由计算机控制的而相机只是跟随它。代码不是很复杂: // [From RenderMenuTrackBackground(), which is called by // RenderMenuBackground(), both located in the UIRenderer class] // [Some code to calculate carPos, carMatrix, etc.] // Put camera behind car RacingGame.Player.SetCameraPosition( carPos + carMatrix.Forward * 9 - carMatrix.Up * 2.3f); // For rendering rotate car to stay correctly on the road carMatrix = Matrix.CreateRotationX(MathHelper.Pi / 2.0f) * Matrix.CreateRotationZ(MathHelper.Pi) * carMatrix; RacingGame.Landscape.Render(); RacingGame.CarModel.RenderCar( randomCarNumber, randomCarColor, carMatrix); 借助于Landscape类的Render方法和Model类的RenderCar方法您不必担心渲染场景、赛道或其他东西。相机矩阵可以确保你在正确的位置上观看,而赛车通过汽车矩阵在赛道上行驶。. 所有其他游戏屏幕菜单也使用RenderMenuBackground方法显示屏幕背景,但在一些游戏屏幕上不太明显,因为你把一个较暗的纹理放在前面(例如,在Credits屏幕上很难看到背景)。这只是一个背景效果,当你正式开始玩游戏时你可以看到更多的屏幕。 主菜单 主菜单(见图14-9)比其他菜单屏幕复杂点,但即使这样这个类也只有约250行代码。除了启动屏幕,其他屏幕都是从这里开始的。最重要的选项是开始游戏和观看highscores (头两个按钮)。 图 14-9 这个类最酷的功能是菜单按钮的动画。每个按钮获得一个介于0和1之间的浮点值,其中0代表按钮尺寸的最小可能值而1代表最大。当鼠标悬浮或用手柄、键盘选择按钮时,它将慢慢变大直到达到1.0。当你离开按钮,它又缓慢变小。 第一个按钮初始设为1,其他按钮设为最小尺寸(0)。 /// <summary> /// Current button sizes for scaling up/down smooth effect. /// </summary> float[] currentButtonSizes = new float[NumberOfButtons] { 1, 0, 0, 0, 0, 0 }; 然后在Rnnder方法中处理按钮选择和缩放。为了确保你会在同时悬浮在一个以上的按钮,这里为鼠标使用了一个辅助变量。如果不使用鼠标选取菜单按钮,这个变量将不会被使用。 // Little helper to keep track if mouse is actually over a button. // Required because buttons are selected even when not hovering over // them for GamePad support, but we still want the mouse only to // be applied when we are actually over the button. int mouseIsOverButton = -1; // [a little later in the code ...] for (int num = 0; num < NumberOfButtons; num++) { // Is this button currently selected? bool selected = num == selectedButton; // Increase size if selected, decrease otherwise currentButtonSizes[num] += (selected ? 1 : -1) * BaseGame.MoveFactorPerSecond * 2; if (currentButtonSizes[num] < 0) currentButtonSizes[num] = 0; if (currentButtonSizes[num] > 1) currentButtonSizes[num] = 1; // Use this size to build rect Rectangle thisRect = InterpolateRect(activeRect, inactiveRect, currentButtonSizes[num]); Rectangle renderRect = new Rectangle( xPos, yPos - (thisRect.Height - inactiveRect.Height) / 2, thisRect.Width, thisRect.Height); BaseGame.UI.Buttons.RenderOnScreen(renderRect, ButtonRects[num], // Make button gray if not selected selected ? Color.White : new Color(192, 192, 192, 192)); // Add border effect if selected if (selected) BaseGame.UI.Buttons.RenderOnScreen(renderRect, UIRenderer.MenuButtonSelectionGfxRect); // Also check if the user hovers with the mouse over this button if (Input.MouseInBox(renderRect)) mouseIsOverButton = num; // [etc.] } // for (num) if (mouseIsOverButton >= 0) selectedButton = mouseIsOverButton; Game Screen GameScreen类(见图14-10)是最重要的游戏屏幕,因为它处理整个游戏逻辑。游戏变量不储存在这个类,但所有的重要组成部分(场景,赛道,汽车,对象,HUD等)从这里被渲染和调用。 图 14-10 大多数玩家变量都存储在Player类,所有的输入和物理在Player的基类中(CarPhysics和ChaseCamera)处理。Player类还使用了所有的游戏变量比赛。大多数变量显示在用户界面中,比如在HUD上的目前的游戏时间,它们在Player类的HandleGameLogic方法中被更新。 所有Render方法,包括HUD,都在UIRenderer辅助类中处理,其余的在Landscape类和model类中处理和渲染,阴影映射也在那里进行。所有的渲染和游戏处理是从这里调用,所以这个类为您提供了游戏中发生了什么的一个很好的概括。如果你编写一个改编版本,那么从这开始修改代码。在这或调用方法中注释掉代码,能很快地看到游戏的哪一部分受到了影响。 Render方法的第一部分处理所有的阴影映射,这不是很复杂,因为大多数已在Landscape类中被处理了。你只需将数据提供给阴影映射类,这个类渲染所有阴影映射,这些阴影映射能直接被使用。 /// <summary> /// Render game screen. Called each frame. /// </summary> public bool Render() { if (BaseGame.AllowShadowMapping) { // Generate shadows ShaderEffect.shadowMapping.GenerateShadows( delegate { RacingGame.Landscape.GenerateShadow(); RacingGame.CarModel.GenerateShadow( RacingGame.Player.CarRenderMatrix); }); // Render shadows ShaderEffect.shadowMapping.RenderShadows( delegate { RacingGame.Landscape.UseShadow(); RacingGame.CarModel.UseShadow( RacingGame.Player.CarRenderMatrix); }); } // if (BaseGame.AllowShadowMapping) 然后开始post-screen glow shader并渲染所有的3D内容。这包括天空盒,带有赛道的场景和所有三维模型,最后是汽车。 // This starts both menu and in game post screen shader! BaseGame.UI.PostScreenGlowShader.Start(); // Render background sky and lensflare. BaseGame.UI.RenderGameBackground(); // Render landscape with track and all objects RacingGame.Landscape.Render(); // Render car with matrix we got from CarPhysics RacingGame.CarModel.RenderCar( RacingGame.currentCarNumber, RacingGame.CarColor, RacingGame.Player.CarRenderMatrix); // And flush all models to be rendered BaseGame.MeshRenderManager.Render(); 在MeshRenderManager渲染所有的三维模型后您可以添加阴影映射效果。这里的调用顺序是重要的,因为如果在显示阴影前还没有渲染三维模型,阴影将不正确或不工作。 // Show shadows we calculated above if (BaseGame.AllowShadowMapping) { ShaderEffect.shadowMapping.ShowShadows(); } // if (BaseGame.AllowShadowMapping) // Apply post screen shader here before doing the UI BaseGame.UI.PostScreenGlowShader.Show(); 代码的最后是游戏的用户界面,如果你想去除或改变HUD,在这里做这件事。 // Play motor sound Sound.UpdateGearSound(RacingGame.Player.Speed, RacingGame.Player.Acceleration); // Show on screen UI for the game. BaseGame.UI.RenderGameUI( (int)RacingGame.Player.GameTimeMiliseconds, // Best time and current lap (int)RacingGame.Player.BestTimeMs, RacingGame.Player.CurrentLap+1, RacingGame.Player.Speed * CarPhysics.MeterPerSecToMph, // Gear logic with sound (could be improved ^^) 1+(int)(5*RacingGame.Player.Speed/CarPhysics.MaxSpeed), // Motormeter 0.5f*RacingGame.Player.Speed/CarPhysics.MaxSpeed + // This could be improved 0.5f*RacingGame.Player.Acceleration, RacingGame.Landscape.CurrentTrackName, Highscore.GetTop5Highscores()); if (Input.KeyboardEscapeJustPressed || Input.GamePadBackJustPressed) { // Stop motor sound Sound.StopGearSound(); // Play menu music again Sound.Play(Sound.Sounds.MenuMusic); // Return to menu return true; } // if (Input.KeyboardEscapeJustPressed) return false; } // Render() Highscores Highscores屏幕(见图14-11)非常相似与Rocket Commander的,但所有在线highscores被移除,因为没有实现网络代码或Web服务。原因仍是XNA缺乏网络支持,但在PC版本中仍有可能实现。 图 14-11 在Highscores类中有几个辅助方法,例如,帮助您确定当前在游戏中的排名,但大多数方法已经这本书的前面几个游戏中用到过了。 下面的代码是用来显示排行榜前10名的玩家。你会发现,借助于UIRenderer类中的辅助方法代码是很简单的。如果您只想测试Highscores游戏屏幕,请使用类内部的单元测试,这是用来定位所有的用户界面元素的,此游戏的其他屏幕也以同样的方式做这件事。我也在Visual Studio 2005使用了TestDriven.NET,它能通过热键重新运行测试。这样我可以测试代码,按下热键,说:“哦,不,”,按下Escape,并修复代码,直到类工作正常。大多数的UI代码都是通过这种方式进行测试的。 // Go through all highscores for (int num = 0; num < NumOfHighscores; num++) { // Show player in white if mouse is over line or else use gray color Rectangle lineRect = new Rectangle( 0, yPos, BaseGame.Width, lineHeight); Color col = Input.MouseInBox(lineRect) ? Color.White : new Color(200, 200, 200); // Fill in text for this line BaseGame.UI.WriteText(xPos1, yPos, (1 + num) + ".", col); BaseGame.UI.WriteText(xPos2, yPos, highscores[selectedLevel, num].name, col); BaseGame.UI.WriteGameTime(xPos3, yPos, highscores[selectedLevel, num].timeMs, Color.Yellow); yPos += lineHeight; } // for (num) 剩下的其他游戏屏幕类是Option,Help和Credit。它们和highscores类非常相似,并不令人兴奋。Option有一些不错的UI功能,可以让你在Input类的帮助下输入文字,选取其中一个或多个滑块并拖动它们。使用Option类的单元测试更多地了解这些功能。Help和Credit类只是在屏幕上显示一个纹理,非常类似于你先前看到的SplashScreen类。 最后,点击退出按钮可以退出游戏,因为主菜单关闭后将不会有其他游戏屏幕了。而所有其他游戏屏幕总是返回到主菜单(包括SplashScreen类)。

调整和改编赛车游戏——游戏构思

clock 十月 17, 2010 00:17 by author alex
游戏构思 在你开始游戏屏幕和游戏逻辑之前,快速浏览一下这个游戏的原始构思,这样你能理解为什么某些东西实现了而有些却没有。在游戏的主要功能实现后,一些部分才在后面添加。这方面的一个例子是阴影映射,这始终是游戏引擎的复杂部分,因为你要花费大量的时间进行调整,直到它看起来正确。还有些东西必须跳过或故意排除在外,因为它们实在太复杂,在短时间内很难实现。最好的例子是道路上的障碍物(例如柱子),如果玩家不小心驾驶就会撞在障碍物上(见图14-2)。 图 14-2 这一想法的主要问题是如何有效率地设置障碍栏。这个游戏没有关卡编辑器或赛道编辑器。所有赛道都是由采样点生成的,很难在3DS Max中设置障碍物。测试这些物体并更新碰撞检测系统会产生更多的问题。在早期测试版中我使用了道路立柱,并将赛道放在立柱上(见图14-3)以测试这个使用障碍物的想法,但它看上去并不十分令人信服,而且只在赛道上开车已经是够难的了。一个更好的解决办法是添加如道路标志,交通灯,垃圾箱等可以破坏的物件,但上一章中你已经看到物理引擎不能够处理许多不同的物体,它只检查汽车与护栏的碰撞,并进行相应的的物理计算,仅此而已。 图 14-3 经过几次单元测试后,我决定放弃这一想法,将精力花在游戏的其他部分。我还添加了隧道和一种简单的方法来设置道路附近的棕榈树,标志,路灯等,并添加一个很好的技术,它能在赛道附近自动添加场景物体。 下列的文字直接来自于最初的游戏构思,这个构思确定于2006年8月,是在2006底开发这个赛车游戏之前。请注意,这个构思写于第一个XNA的beta版本发布以前,那时我还不知道XNA的许多细节。 游戏理念 这个赛车游戏是一个简单的3D赛车游戏。玩家能看到前面的赛车并能使用Xbox手柄或鼠标或键盘直接控制赛车。游戏的主要目标是尽可能快的到达终点并绕过所有障碍物。碰撞不会伤害玩家,但赛车会减速导致完成时间大大增加。玩家不会游戏结束,他只需花很长时间完成赛道。有3个不同难度的赛道,每个赛道都有一个排行榜。(注:这本书中的赛车游戏版本没有实现多个轨道,也没有最高分数或奖杯,请查看XNA的赛车游戏初学者工具包获取更多细节和附加功能) 这个游戏的图形(见图14-4)借鉴了如赛道狂飙,GT赛车,极品飞车等类似的游戏。显然,这些游戏花了很长时间开发并有很漂亮的图像,这在短时间内是不可能完成的,因此这个游戏着重于创建一个简单易学的、只有基本功能的赛车游戏。主要图形的主题是位于城市(显示街道,一些简单的建筑物,以及一些树木)。地面使用一个简单的二维图形,赛道是从二维图像自动生成的(使用特定间隔的点),使用高程图创建山较难,而创建赛道更难。 (请注意,这一想法后来被放弃,现在游戏中导入的是真正的三维数据。) 图 14-4 在游戏中没有其他汽车(如赛道狂飙),这使得代码简单,只需检查的与关卡中物体的碰撞。游戏的基本版只有一个主菜单,三个简单的单人赛道和每个赛道的排行榜(显示完成的时间)。 更多的图形设置(雪,沙漠,草原等等),更多的赛道,改编版本或多人游戏代码会在以后制作(例如在XNA社区)。 由于开发时间短,所以只保留了最基本的构思:控制汽车通过赛道。一个简单的物理系统被实现,每帧检测与场景中的静止物体的碰撞。其他构思在基本游戏中被放弃。这不仅简化了游戏,但简化了对技术和代码的理解。 这个游戏是从后面看,在前面显示汽车,赛道在中间,一些物品和天空在远处。天空是由一个天空立方体贴图实现的。 接下来是开发时间,所有的功能,哪个项目文件被使用,shader如何工作,音效如何使用。当讨论到使用的技术时会变得更有趣一点。 微软公司的赛车游戏初学者工具包使用C#和XNA Game Studio Express开发。使用XNA? API和托管代码有助于编写和阅读代码、执行如载入纹理、使用shader、只用一行代码显示模型等强大的功能。 图形都是.DDS格式的文件。所有模型(汽车,树木,Effects等等)都在3D Studio Max8中建模并导出为.x文件。模型不使用动画,这样更容易理解代码(注:我后来发现XNA不支持动画模型)。 这个游戏不使用任何现有的引擎或其他复杂的框架,这样做的话那些非游戏程序员会不熟悉它,只会使想要看源代码的人困扰。游戏由以下几个基本部分组成: Helper类处理纹理加载,模型加载,游戏屏幕处理,XNA管理,处理控制等。 Shader类处理渲染模型(主要是汽车)和带有shader效果的场景。(注:我很快发现我低估了场景物体的数量) Pre和post-screen shader类处理渲染天空和增加光晕效果、运动模糊和色彩校正。 主菜单类处理开始游戏,检查排行榜和退出游戏。 排行榜屏幕显示排行榜,并提交给一个在线服务器。排行榜还保存在本机上,也可通过这个类查看。(注:由于XNA不支持网络,而我想为PC和Xbox 360使用相同的代码,所以我放弃了将排行榜发送到在线服务器。) 在Mission类中玩游戏。 这几乎就是所有的游戏构思了,当得到第一个XNA beta版本后我立即开始了编程。当然开发过程中还有许多问题,但借助于单元测试和游戏构思的不断更新,开发并不难。最难的部分是场景和赛道(见第12章)和第13章的物理系统。阴影映射对大多数没有经验的编程这也不是一件容易的事,但几个月后我在另一个引擎中实现了阴影映射,回过头来看未完成的赛车游戏,我确信没有阴影映射它不会很好看。 附加功能 如果你看以下整个赛车游戏的类视图(见图14-5),它看起来相当复杂。最大的帮助是Rocket Commander的项目,在它的单元测试和代码帮助下,我能很快地测试自己的想法,模型和渲染技术。后来当XNA引擎完成后,我就可以复制代码,并在新引擎中继续测试。例如,我很早就发现从二维图像产生赛道并不聪明,造成的问题比解决的更多。实现基本赛道不难,但当需要处理三维顶点是,我很高兴Rocket Commander游戏中有一些托管代码让我可以加载.x文件并使用它们的顶点生成赛道。但是XNA不支持访问或加载导入的3D模型的顶点,你可以编写自己的内容导入器,但要做太多的工作。后来我改变了赛道的导入方式并添加了许多功能(隧道,道路宽度,道路物体、棕榈树等)。在3D Studio Max中赛道被直接导出为Collada格式,然后以二进制格式(更快地装载)导入到游戏引擎中。 图 14-5 以下的功能是从以前的项目(主要来自于Rocket Commander)中导入的: Helper类,如Log类处理调试,StringHelper类或其他如RandomHelper,Vector3Helper,或ColorHelper类等重要的类。 该图形引擎是从头写起的,但通过扩展XNA类的功能,使用新的类处理纹理,材质,模型,字体的基本思路是来自与Rocket Commander。XNA中的大多数类简单得多,一开始做得并不多,比如说Model类,只加载了一个模型并通过几行代码显示它。但后来的模型渲染性能提高了很多(将在本章后面讨论),Model类的内在逻辑完全改变了。模型已不再直接被渲染,取而代之,每个三维模型的shader和网格被收集并在每帧末所用使用相同shader、材质、网格数据的网格被集中渲染。通过这种方式在Xbox 360游戏机上能使性能提高200% -300%。借助于特殊模型类的抽象,使用模型的代码也无需改变,它会变得更快! 游戏屏幕逻辑100%地来自Rocket Commander。我很喜欢这个主意,而且实现起来很简单。 shader和其他图形类如ParallaxMapping,PostScreenGlow,PreScreenSkyCubeMapping和LensFlare类也是从Rocket Commander导入的,但经过一段时间后只有LensFlare和Sky Shader被留了下来,因为其他shader改变得太多了。无需为每一个新的材质编写一个新的shader,作为替代,编写一个更一般的ShaderEffect类,现在所有shader都是从这个类继承的。这简化了一些shader,并使像NormalMapping或ParallaxMapping变得过时了,因为它们现在直接工作自ShaderEffect类,而ShaderEffect类可以处理所有shader参数。PostScreenGlow shader继承自PostScreenMenu,这个新的shader仅用于菜单。 对于单元测试也采取了类似的做法,但NUnit和TestDriven.NET都被排除在外以简化Xbox 360的开发,因为TestDriven.NET在Visual Studio的Express版本不能工作。 当然这个游戏也有一些新的功能,不光是阴影映射,还使用了以下几个类: RenderToTexture:这个类服务于post-screen shader,但需要进行改进以支持阴影映射,这要求有不同的表面形式以获得更好的精度。这意味着,对普通的渲染目标你只能使用与后备缓冲相同的格式。这最有可能是R8G8B8X8,这意味着每个颜色通道使用8位而有8位是保留的,因为alpha通道对后备缓冲是无意义的,但32位优于24位,这使得在32位平台上更难读取像素数据。 总之,阴影映射需要每像素更多的精度而不是很需要颜色。阴影映射的每个像素的深度值必须介于0和1之间 ,并越精确越好。良好的格式是R32F,如果R32F无法使用则R16F也可。但是,这些格式都必须进行不同的初始化,而RenderToTexture会为你处理。它也会根据用户的选择决定阴影映射大小是2048×2048,1024×1024或在速度较慢的电脑上只使用512×512。如果R32F和R16F都不能使用,它也支持回退到32位颜色格式。 ShadowMapBlur:这个类可以帮助您模糊阴影映射。所使用的技术类似于PostScreenGlowshader的发光技术。它通过两个步骤获取输入数据并模糊。模糊不仅使阴影更柔和更真实,也修正某些阴影映射错误,这些错误被模糊掉了。 这种技巧在XNA Shooter中不使用,因为如果在背景上有很多小物体和很多阴影重叠区和非阴影区,看起来不太好。在赛车游戏看上去好得多,特别是汽车上的阴影有一些错误变得更加光滑。其他场景物体大多相距遥远,它们的阴影不会和其他物体重叠,这可以让你使用更好的模糊值。 ShadowMapShader:这是阴影的主要类。它初始化所有渲染目标,使用的纹理,阴影映射shadr,它处理ShadowMapBlur类。关于阴影映射技术的更多内容将在本章后面讨论后。 其他的游戏功能,比如环形赛道和场景渲染引擎已在第12章讨论过。它们包含在TrackLine类和Track类早期单元测试中,使游戏更有趣。 我真的不记得为什么游戏构思没有提及游戏屏幕。好像只说过:主菜单允许开始任务,查看排行榜,并退出游戏。代码后部是一个非常复杂的game screen命名空间(见图14-5),它比原计划支持更多的游戏屏幕。请注意,本章中某些游戏屏幕没有在代码中实现(例如,赛道或汽车选择屏幕),他们只用在XNA框架的赛车游戏初学者工具包中。最主要的原因是这里你只有一个赛道和一辆汽车,如果没有什么可以选择,那么实现它们是毫无意义的。你可以在http://www.xnaracinggame.com,这个游戏的官方网站找到一个更复杂的版本,并获取更详细的信息。

调整和改编赛车游戏——概览

clock 十月 17, 2010 00:14 by author alex
概览 终于到本书的最后了,只剩最后一章了。你将了解赛车游戏所有的细节。本章你将学习所有的游戏屏幕、游戏逻辑、记分系统以及一切你您需要的东西。场景和赛道的大多数渲染技术已经在第12章讨论过了,但本章会增加一点阴影。因为上一章已讨论过物理学和汽车控制,现在应该可以插入此代码,并开始测试游戏本身。 本章的主要目的是让你熟悉赛车游戏和底层代码。在本章最后你应该知道一切以创建自己的改编赛车游戏。游戏改编可以很简单,只需改编图形和三维模型,也可以改变整个游戏的逻辑,使它不再是一个赛车游戏。例如Rocket Commander也是可以改编的,但游戏逻辑和渲染代码是固定的。你可以或多或少地改变模型,并新增一点游戏逻辑。尽管大多数Rocket Commander改编版本看起来完全不同(见图14-1),但游戏逻辑是相似的。Rocket Commander的游戏规则是保持前进并避免与其他物体(如小行星)碰撞,同时也收集物品,如生命值,水果,比萨饼等等。有些改编版本有更快的游戏速度,有些则慢得多,侧重于解决智力题,比如收集正确的水果(比萨指挥官和水果指挥官)。好在改编Rocket Commander并不难,因为改变原始版本相对容易。如果有一些不同的三维模型,则改变它们是容易的。改变游戏逻辑也不难,因为你只需关心Mission类和Player类,其余的类只是用于菜单和图形。 图 14-1 最新的改编版本(写这本书时)是峡谷指挥官,它表明无需太多努力也能大大地改变图形和游戏玩法。该版本是由一个加拿大的.NET初学者制作的,这是他的第一场游戏项目。我想说这令人印象深刻。 本章的赛车游戏更加复杂,并允许更大的游戏变化。使用不同的代码和三维模型,可以制作车一个完全不同的游戏。场景引擎可在战略或角色扮演游戏中找到,但即使是室外的射击和冒险游戏也可以大大受益于现有的场景渲染引擎。不过,制作一个完整的改编版本和改变整个游戏的逻辑要做很多工作,如果你的改编版本不需要,还可以删除负责生成道路的代码。 显然,保持同一类型而只改变三维模型,也许再调整一些游戏参数简单得多。这也是你将在本章最后要做的事,那时你可以自由地实现自己的游戏构思。Speedy Racer改编版本替换了赛车模型,并使道路更加宽阔使驾驶更加容易,但它大大提高了车速。这个改编版本将在本章最后详细讨论。

物理学——总结

clock 十月 16, 2010 23:58 by author alex
今天物理学已成为了游戏的一个重要组成部分,但你也看到它不是很容易将一个现成的物理引擎在XNA中实现。但是,你仍然可以通过自己的方式实现物理引擎,并可运行于Xbox 360上,而Xbox 360上是不允许非托管代码的。 物理学有许多议题,可以肯定全部都自己实现是不容易的,即使有一个良好的物理引擎你还必须做大量的调整和测试。然而,如果与你竞争的另一个游戏有大量的物理效果又不破坏游戏的乐趣,那么相对你的只有基本规则的游戏,那个游戏会更好。从这章的篇幅和在本书中的位置,你也应该意识物理不是一个简单的议题,特别是对初学者来说。虽然本章未能涵盖物理引擎的方方面面,但至少已了解了现有的物理引擎。如果你有兴趣阅读更多有关物理学的知识,可以参考网络、很多好书或如Game Programming Gems之类的系列书上的资料。 赛车游戏、游戏逻辑和如何处理每件事将在下一章讨论。本书的其他游戏也使用了一点物理效果,但你可以忽略许多其他问题。例如,XNA Shooter只处理飞船,敌人,弹丸运动和通过检查距离处理简单的碰撞检测,但游戏运行良好。同样的方式,在赛车游戏中你只实现必要的物理效果,即不要多也不要少。 本章你学习了以下议题,这些都用于赛车游戏: 牛顿运动定律可能有点老,但它们仍然是有用的,让你可以快速在游戏实现简单的物理效果。著名的公式F=ma也被使用了几次,它是大多数物理计算的最重要的公式。 ? 始终保持尽可能简单,将问题分解成较小的问题,并编写单元测试以找出下一步该怎么办,这样你就不会编写将来不会用到的任何不必要的代码。 始终保持尽可能简单,将问题分解成较小的问题,并编写单元测试以找出下一步该怎么办,这样你就不会编写将来不会用到的任何不必要的代码。 你学习了今天可用的所有物理引擎。PhysX和Havok是主要的两个,但它们都是商业引擎,你无法将它们插入XNA中,而且它们使用非托管代码所以你也不能期望能工作在Xbox 360上。因为它们收费很高,你可能会想使用一个免费的,有很多示例和教程,但遭受同样缺乏对. NET的支持。ODE.NET是一个很好的解决办法,因为很多工作已经完成,虽然它只支持一些基本功能,但它仍然是一个非常强大的物理引擎,可以节省你几个月的工作。 然后你一步步地解决了在赛车游戏中遇到的问题,如冲过环形赛道、处理弹性物理以及如何处理碰撞。Rocket Commander使用一个更为复杂的碰撞检测系统,但因为你只检查包围球是否碰撞,所以碰撞本身是相当简单的。 当检测到碰撞后必须加以处理,要使碰撞检测更精确,你将花费大部分的时间去调整代码,以确保碰撞是真实的。在赛车游戏我没有太多的时间去做更多的调整,但基本的规则已经实现了,任何人如果有兴趣来改进游戏,可以添加更多的物理碰撞和计算。 在本书中的最后一章第14章,你将学习赛车游戏的所有知识,以及如何创造改编版本,这将是相当令人激动的。 /*728*90文章内页广告*/ var cpro_id = 'u241919';

物理学——挑战:实现道路碰撞检测

clock 十月 16, 2010 23:57 by author alex
如果前一章你觉得容易,现在将有点难。你的任务是查看CarPhysics类并弄懂如何相同的物理代码和碰撞检测是如何将车保持在赛道上,以及如何处理与护栏(不是一条直线)的碰撞。 要完成此项任务,最好是写一个单元测试,它绘制赛路,并让你以在TestCarPhysicsOnPlaneWithGuardRails单元测试中同样的方式控制汽车,以同样的方式处理游戏本身。但是,你也可以在CarPhysics类的Update方法设置断点来测试物理效果,然后通过步进代码看看变量是如何变化的。图13-16显示了这样一个单元测试。 Figure 13-16 如果你真的像扩展游戏并增加更多的功能,可以尝试改进物理效果甚至尝试实现一个物理引擎。至少在Windows平台上有可能改变游戏玩法,增加特效,并处理更复杂的碰撞情况。 一个小提示,如果你不知道在哪里寻找代码,或不知道赛道如何使用简单碰撞逻辑,请看看CarPhysics类UpdateCarMatrixAndCamera方法和Track类的GetTrackPositionMatrix方法。赛道矩阵将目前的道路片段转换为空间,以相同的方式使用汽车矩阵,两者在单元测试的平面碰撞检测中使用。 /*728*90文章内页广告*/ var cpro_id = 'u241919';

友情链接赞助