现在您拥有了游戏的所有类,但还没完。我们已经谈到了几次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)。
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单元测试,特别是如果你想改变一些在这个类中定义的一些常量,如汽车质量,最大速度,最高转速和加速度。
ChaseCamera类似于Rocket Commander中的SpaceCamera类或XNA Shooter中的SimpleCamera类。这个类不是很复杂,它继承自CarPhysics类,其中为您提供了几乎所有您所需要的东西。它支持两个相机模式:游戏或菜单中使用的Default模式和主要用于单元测试的FreeCamera模式。
BaseGame类的观察矩阵在这里更新,如果您需要获得摄像机的位置,旋转矩阵或旋转轴,可以看这里。您可能不会频繁地使用这个类,因为游戏的大多数重要信息,如汽车的当前位置或游戏时间能从BasePlayer和CarPhysics类的属性中获得。
您现在已经知道如何改变一般的游戏逻辑规则,但大多数的关卡是从level数据直接定义的。正如你在第12章中所见,创建赛道并不容易,将赛道数据导入到游戏中也不简单,因为你需要做以下的事情(见图14-13):
您需要3D Studio Max打开一个赛道并修改它。可能使用其他3D建模程序也行,但尚未测试过。作为一个游戏程序员你可能没有这些工具。
最后你必须自己测试赛道,或者通过开始并玩游戏,或者通过使用Track和TrackLine的单元测试。
是的,这个不是很理想,我会在以后制作一个赛道编辑器来解决这一问题。请查看官方网站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。如果您想测试其他三维物体的阴影,您可以用其他任何三维模型替换汽车。
在本章的前面你看到了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)。请注意,阴影映射模糊效果隐藏了错误,但它们越强,模糊效果越明显,即使有良好的模糊代码应用到阴影映射,在游戏中仍会出错,特别是当移动相机时。
/ 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而不是使用阴影映射。
好了,有了这么多代码,现在可以进行游戏测试了。在您启动游戏并在赛道上驾驶挑战最高分前,您应该确保已经查看了游戏引擎中的大部分单元测试(见图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)制作安装文件,如果用户没有安装以上这些框架,它能自动下载并安装。之后你就可以享受游戏了。
显然你也希望你的游戏在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.]