场景绘制
在前一章你已经看到了在.x模型的帮助下用简化的方式来产生三维场景。你首先创建了diffuse纹理,并由此建立了一个法线贴图,最终添加了一个高程图为XNA Shooter生成了峡谷。
对于这个游戏你仍然要使用一个非常简化的办法去渲染场景,因为开发时间很短,而且制做一个高精度的场景渲染引擎往往要花费几个月的时间。如Arena Wars游戏中使用的场景引擎,支持非常大的场景,但它从来没被使用在游戏中,在不同的硬件配置中进行优化要做大量的工作,尤其是还要支持24中不同类型的地面纹理。
为了保持简单,这个赛车游戏只使用一个非常大的地面纹理,但你也需要一个法线贴图和一个额外的细节纹理用来添加一些细节,特别是当你离地面非常近时,这种情况通常发生在你停留在非常靠近场景的时候。一开始我制作了一个4096×4096的纹理,但它很难被更新,包括法线贴图也是。未压缩的diffuse纹理约有64MB,另外还要64MB用于法线贴图。想像一下在一台只有1GB内存的电脑上创建10个或更多的这种纹理,结果一定很糟。即使压缩为一个DXT5纹理,4096×4096的法线贴图仍有约12MB,单单载入过程就很恼人。另一个让我放弃使用4096×4096纹理的原因是XNA中的内容管道,当它把这么大的一个纹理转换到.xnb内容文件时耗时很长。
我缩小到2048×2048纹理,这看起来与4096×4096几乎一样好,但只占用1/4的大小和载入时间。法线贴图甚至降低到1024×1024,因为它看起来没多大差别。另一个不使用大尺寸纹理的原因是微软的Xbox 360显存大小有限(64MB),你不应载入太多的东西或太大的纹理,否则性能将大大下降。不要建立一个超大尺寸的纹理,作为替代,我添加了一个细节纹理,使镜头拉近时场景看起来更好,在下面几页会讨论到如何绘制这些纹理。
纹理
首先,你需要一个高程图,并知道你的场景有多大。最初我想让一个场景的texel(纹理像素,纹理的1个像素)是1米(3.3英尺),那么4096×4096纹理使整个场景大小为4×4公里大(约2.5×2.5英里)。在纹理减少到2048×2048时场景大小保持不变,则现在每个纹理像素是2×2米。
那么你的场景从哪里获得高度值?自己画可能不会非常好,你也没有一个好的程序帮你做这件事,也肯定没有足够的时间编写一个自定义场景高程图编辑器。一个好主意是在互联网上寻找并使用现成的高程图,有很多资源可以利用(国家地理,美国航天局NASA,其中甚至还有其他行星的高程图)。
对于这个赛车游戏我想有一些山。对测试来说足够好了,但后来我需要山围绕在场景周围,所以我必须要修改高程图,使场景中间是座大山,其他山体围绕在边界上。最后的高程图中可以看图12-5。请注意,这个游戏使用的LandscapeGridHeights.png高程图只有257× 257个像素,因为在Landscape类中只能生成257×257个(66049个)顶点的网格,即256* 256*2个多边形(约13万个多边形)。太多多边形会极大地拖慢渲染速度,但处理130000多边形对今天的显卡来说不是大问题,Xbox 360也能处理得很好(仍保持在数百帧每秒)。
你可能会注意到,高程图上用于城市地区的平坦地区,只是简单地把所有建筑物和物体放置在相同的高度上。中间的白色区域是大山,围绕在地图边界上的白灰色部分表明边界的山体。
这个高程图与一个有一点起伏的纹理产生法线贴图。另外,你也可以像上一章一样混合diffuse贴图,但是因为我要频繁地改变diffuse贴图导致法线贴图往往不再受到diffuse贴图的影响。图12-6显示了使用了diffuse贴图和法线贴图的游戏场景。
请注意,对这些纹理我尝试了很多次直到它们成为现在这个样子,我并不完全满意,但你必须适可而止,尤其是当你没有更多的时间进行改进时。例如,法线贴图从远处看很棒,但靠近时缺少变化,或许通过更好地匹配diffuse纹理可以加以改进。无论如何,在游戏中它们看起来很好,我也没有听到任何抱怨。
最后,在你靠近场景时还添加了细节贴图。你不会马上注意到细节贴图,但图12-7显示了使用与不使用细节贴图之间的令人信服的差异。在这个赛车游戏中有这么大的一个场景,并允许拉近镜头,如果没有细节纹理,画面表现是不好的。
渲染
如果你打开Racing Game的项目,你可以看到很多从以前章节而来的类,但还有两个新的命名空间将会在本章中讨论:Landscapes和Tracks。在Landscapes命名空间只有一个类Landscape(见图12-8),它负责渲染场景,包括所有在场景上的物体,所有赛道和赛道上的物体,基本上是除了你的车以外的所有东西。在Misson类你只是调用Landscape的Render方法执行所有的渲染。对于阴影映射有几个辅助方法可用,有关阴影映射的细节将在第14章中讨论。
所有场景物体都在Landscape类被创建是被建立,尤其是在Track类的构造函数中,它被用在了Landscape类内部。你马上就可以看到本游戏中使用的3D模型。
要渲染场景,你首先必须做的是在构造函数中产生它。在你查看构造函数前你应看看Landscape类中的TestRenderLandscape单元测试,在类实现前应先写单元测试。你也注意到了其他单元测试,GenerateLandscapeHeightFile会为场景高程图产生一个关卡文件,并生成一个特殊的内容文件,这个方法和在Rocket Commander中的一样,因为在Xbox 360中加载位图数据是不可能的。
/// <summary> /// Test render landscape /// </summary> public static void TestRenderLandscape() { TestGame.Start("TestRenderLandscape", delegate { RacingGame.LoadLevel(RacingGame.Level.Beginner); RacingGame.Landscape.SetCarToStartPosition(); }, delegate { 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) BaseGame.UI.PostScreenGlowShader.Start(); BaseGame.UI.RenderGameBackground(); RacingGame.Landscape.Render(); RacingGame.CarModel.RenderCar(0, Color.Goldenrod, RacingGame.Player.CarRenderMatrix); // And flush render manager to draw all objects BaseGame.MeshRenderManager.Render(); if (BaseGame.AllowShadowMapping) ShaderEffect.shadowMapping.ShowShadows(); BaseGame.UI.PostScreenGlowShader.Show(); TestGame.UI.WriteText(2, 50, "Number of objects: "+ RacingGame.Landscape.landscapeObjects.Count); }); } // TestRenderLandscape()
该单元测试做了很多事情,它显示了汽车和所有的场景对象。赛道、所有的阴影映射和post-screen shaders也在这里测试,以确保它们在场景中也能工作得很好。如果你只想测试场景本身,只需调用Landscape类中的Render方法就够了。
RacingGame类中的LoadLevel方法是这个游戏主要的类,它负责载入一个关卡。所有关卡使用相同场景,这意味着你不必重新加载它。但是,你应该检查生成场景顶点的代码。场景构造函数执行了以下操作:
-
从关卡文件载入高程图数据图并建立切线顶点
-
生成和平滑整个场景的法线,也从新的法线中重新生成切线
- 设置这些顶点的顶点缓冲
-
计算索引缓冲(与你在上一章中看到的场景三角形类似)
-
载入和生成当前关卡的赛道数据,包括所有的场景物体
-
最后,添加额外的物体,如城市地面,给城市物体更好看的地面纹理。.
构造函数最重要的部分是从高程图生成切线顶点,它通过遍历高程图中所有257×257个点为你生成顶点:
// Build our tangent vertices for (int x = 0; x < GridWidth; x++) for (int y = 0; y < GridHeight; y++) { // Step 1: Calculate position int index = x + y * GridWidth; Vector3 pos = CalcLandscapePos(x, y, heights); mapHeights[x, y] = pos.Z; vertices[index].pos = pos; // Step 2: Calculate all edge vectors (for normals and tangents) // This involves quite complicated optimizations and mathematics, // hard to explain with just a comment. Read my book :D Vector3 edge1 = pos - CalcLandscapePos(x, y + 1, heights); Vector3 edge2 = pos - CalcLandscapePos(x + 1, y, heights); Vector3 edge3 = pos - CalcLandscapePos(x - 1, y + 1, heights); Vector3 edge4 = pos - CalcLandscapePos(x + 1, y + 1, heights); Vector3 edge5 = pos - CalcLandscapePos(x - 1, y - 1, heights); // Step 3: Calculate normal based on the edges (interpolate // from 3 cross products we build from our edges). vertices[index].normal = Vector3.Normalize( Vector3.Cross(edge2, edge1) + Vector3.Cross(edge4, edge3) + Vector3.Cross(edge3, edge5)); // Step 4: Set tangent data, just use edge1 vertices[index].tangent = Vector3.Normalize(edge1); // Step 5: Set texture coordinates, use full 0.0f to 1.0f range! vertices[index].uv = new Vector2( y / (float)(GridHeight - 1), x / (float)(GridWidth - 1)); } // for for (int)
你可以看到此代码通过五个步骤生成顶点。首先,计算位置矢量。然后,计算所有边缘向量,从而从三个叉积中构造出法线并分配切线。最后,纹理坐标被分配,但你倒装了x和y 使以后的xy渲染更容易,但仍需正确地对齐纹理贴图使它看起来像一张位图。顶点列表已在定义时产生,因为只支持257×257高程图网格,该CalcLandscapePos辅助方法很简单,只是从高程图数据中提取高度向量:
private Vector3 CalcLandscapePos(int x, int y, byte[] heights) { // Make sure we stay on the valid map data int mapX = x < 0 ? 0 : x >= GridWidth ? GridWidth - 1 : x; int mapY = y < 0 ? 0 : y >= GridHeight ? GridHeight - 1 : y; float heightPercent = heights[mapX+mapY*GridWidth] / 255.0f; return new Vector3( x * MapWidthFactor, y * MapHeightFactor, heightPercent * MapZScale); } // CalcLandscapePos(x, y, texData)
所有的顶点和索引生成后现在终于可以在LandscapeNormalMapping.fx的帮助下渲染场景了。你会不相信这有多容易。下面的代码渲染130000的多边形,同时还包括使用了diffuse贴图,法线贴图,以及额外的细节贴图的LandscapeNormalMapping shader:
// Render landscape (pretty easy with all the data we got here) ShaderEffect.landscapeNormalMapping.Render( mat, "DiffuseWithDetail20", delegate { BaseGame.Device.VertexDeclaration = TangentVertex.VertexDeclaration; BaseGame.Device.Vertices[0].SetSource(vertexBuffer, 0, TangentVertex.SizeInBytes); BaseGame.Device.Indices = indexBuffer; BaseGame.Device.DrawIndexedPrimitives( PrimitiveType.TriangleList, 0, 0, GridWidth * GridHeight, 0, (GridWidth - 1) * (GridHeight - 1) * 2); });
来自于第7章的ShaderEffect类让你可以通过使用RenderDelegate代码的特别技术渲染场景材质。
Landscape类中的Render方法还渲染赛道和所有的场景对象。赛道渲染将在本章的其余部分讨论。我们不会谈论所有游戏中用到的模型,因为太多。看一下Model类的TestRenderModels单元测试可以看到全部,可在图12-9中快速浏览一下。
优化技巧
处理场景引擎不容易。即使你已经制作出了一个很棒的场景引擎,支持许多不同的shader和纹理集,还是要担心性能。另一方面,如果你已经拥有一个性能很好的场景引擎,比如来自本章的赛车游戏或上一章的射击游戏,仍可能还想要改善视觉质量而又不影响它的性能。让场景引擎能适合你的游戏是一个棘手的挑战。正如我以前告诉你的,以前我试图创造一个更加复杂的场景和图形引擎,可以创造出比最终用到的大100倍的巨大场景,但在进行了优化后,从来没有利用到这些功能。
相反你应把重点放在游戏做的事情上。以赛车游戏为例,一个固定的场景很容易实现,它始终是相同的。所以测试比较容易,你可以为其他关卡重复使用现有的部分场景和物体而无需重新设计一切。对许多不同的轨道来说场景还不够大(4096×4096米),因为第一关只向你显示了一小部分场景(大概20%~30%)。
本章和下一章使用的场景渲染技术有三个缺点:
-
你不能随意改变场景大小,这将涉及大量的工作。如果你已经使用4096× 4096纹理,那么可能无法再进进一步提高纹理质量了,这时如果非常接近地面,即使使用了额外的细节纹理,看起来仍会非常模糊。
-
改变diffuse纹理贴图很难。你要自己组合不同类型的纹理,但还是很难看到结果并仍要涉及很多测试。更糟的是,纹理越大,开发时间越长。
-
场景引擎还不是足够强大到能处理高级效果,比如在地上添加陨石坑或增加额外的纹理(轨道,公路,树叶等等),你甚至不能动态地改变外观。这意味着场景是一成不变的。如果想建立一个关卡编辑器,你还需要一个更加灵活的解决方案,允许你更改纹理种类并自动将其组合在一起。
绕过这些问题的最好办法是使用一种叫做splatting的场景渲染技术,这种技术使用了一系列的纹理,并把渲染到一个使用与高程图相同分辨率的纹理贴图。因为把不同的纹理并排在地面上看起来不是非常好,你需要进行插值。你可以减弱纹理块,但这样做看起来会太粗糙,或者你也可以为每个纹理类型保存百分比值。
场景与地面纹理是分开渲染的,你应该让最低的一个完全不透明,以确保你不会看到场景背后,而地面纹理是带alpha混合的(见图12-10)。
你也可以使用一个alpha混合纹理或只是通过色彩值混合(见图12-10的例子)。由于在XNA中使用shader,你可以将四个(pixal shader 1.1)或八个(pixel shader 2.0)的纹理合并在一个shader pass中去优化性能。如果你的场景引擎需要处理更多的纹理,你将需要多个pass直到一切东西都被正确渲染。在不渲染所有的而只渲染可见的那一个纹理有时可以更快。当你生成顶点和索引缓冲时将变得更难,但如果你有很多的纹理,如20或30个不同的地面纹理,性能将大大增加,因为每个地面纹理类型只使用了10%或更低,一直渲染是无意义的。
无论你选择何种技术,我强烈建议你先从简单的着手,比如渲染只有一个纹理平铺于上的大场景,然后加以改进。你可能还希望在添加了所有场景模型、3D物体、效果后,游戏仍能达到几百帧每秒的速度。
创造良好的场景还有更多的技巧和窍门。你可以实现预先计算的照明和阴影,也有很多机会使用更棒的shader,尤其是场景中包括水面时。草地可通过fur shader实现。岩石和峭壁的外观可通过parallax映射甚至offset映射加强效果。如果你对这些专题感兴趣可参见关于shader的书籍,比如Game Programming Gems和ShaderX,或通过互联网搜索技巧、窍门和教程。
图12-11显示了在使用post-screen shaders、HUD以及天空盒映射(如果你忘了,可见第5和第6章详细了解这些类型的shaders)后,最终的场景表现。