RacingGame学习笔记5——基础图形部分4

 

文件八:Material.cs

读者如果接触过3DMaxMaya3D设计软件,应该对材质这个概念并不陌生。但我们首先要明确的一点是,图形设备的渲染过程中是没有材质这个概念的。当前的图形设备一般只需要外界传给他贴图数据和顶点数据,告诉它对这些数据该怎么处理。(DirectX9.0之后的版本使用HLSL来控制处理过程,也是XNA中使用的控制方法。下一节讨论Shaders目录的时候将重点讲解HLSL在游戏中的应用。)而不论是3D设计软件还是本游戏中的材质,都来自于程序中的抽象。抽象的对象就是那些与物质特征有关的量。如这个Material类中包含的漫反射颜色、高光颜色、高光度(一个在计算高光中用来在高光强度值上加权的量)、细节贴图、上个文件中所说的法向贴图、高度贴图等。在我的记忆中,3DMax的材质系统还可以调整包括金属材质的光泽特征等参数。

那么,接下来我们就一起来看看材质中定义的这些抽象内容是怎样最终作用于渲染过程的。

Material类的Variables区间中,定义的就是飞车中的材质的可调参数。

先是三个颜色成员的定义:

diffuseColor:漫反射颜色。

ambientColor:物体在环境光照射下的颜色基色。

specularColor:物体的镜面反射基色。

他们的定义实际上基于计算机图形学中的一个经典公式:

反射光=环境光+漫反射分量+镜面反射分量

继续:

specularPower:高光度,前面已经解释了。这个参数具体的作用在下一节会涉及,在这里不要太急了。(注意“高光度”这个词是我个人的称呼,并不具有通用性)

然后是几个贴图的定义,这些贴图在实际使用中都是可选的:

diffuseTexture:漫反射贴图。

normalTexture:法向凹凸贴图。

heightTexture:高度贴图。游戏中只在绘制地面时使用。

detailTexture:细节贴图。同样只在绘制地面时使用。

最后一个:

parallaxAmount:从参数的名字我们可以推断是准备用于另一种凹凸贴图技术——视差贴图。

而这个文件其他的内容就只是构造函数和Dispose函数了。

简单的浏览过了文件的内容,让我们看看在程序的渲染过程中是怎样利用这个类的。

首先要注意的是Constructors 区间中有一个参数为Effect的构造函数。其中的语句都是像这样:

EffectParameter diffuseTextureParameter = effect.Parameters["diffuseTexture"];

if (diffuseTextureParameter != null)

      diffuseTexture = new Texture(

      diffuseTextureParameter.GetValueTexture2D() );

先来说说这个Effect类。在XNAFramework中,Effect类通过读取HLSL文件来控制渲染管道的渲染过程。也就是说,他一方面是程序的代码能与HLSL文件进行交互,(设置HLSL文件中的某些参数值,以及读取HLSL文件中的一些信息。)另一方面程序代码通过调用Effect类的特定方法来执行实际的渲染过程。

这段代码的第一行,便是从当前effect对象包含的HLSL文件中提取diffuseTexture的信息。接下来判断HLSL文件中是否包含diffuseTexture参数,如果是,而且diffuseTextureParameter被正确的初始化了,那么使用diffuseTextureParament中的信息初始化自身成员diffuseTexture

可见这个构造函数的作用就是通过读取HLSL文件中参数的默认值类来初始化自身的成员。

接下来我们从另一个方向来调查:查找对Material类中任意一个实例成员(非静态成员)的引用。可以发现无论那一个实例成员都会被一个叫ShaderEffect.cs的文件引用。在这个文件中定义的ShaderEffect类实际上是对Effect类的封装。让我们定位到这个文件中对所查的实例成员的引用处。

出现在我们面前的是ShaderEffect类中的SetParameters系列函数。其中有两个函数的参数的类型是这里的Material。函数的作用,便是在进行渲染过程之前设置好HLSL文件中的参数。例如在SetParameters函数中,有这样的代码:

if (worldViewProj != null)

      worldViewProj.SetValue( BaseGame.WorldViewProjectionMatrix );

这里的worldViewProj是一个EffectParameter类型的变量,它实际上已经与HLSL文件中的参数进行了绑定。而这里SetValue的作用就是将实际值设置到HLSL文件中。

于是我们可以看到在SetParameters函数的末尾,有一段类似将setMat参数中的值赋值给ShaderEffect的成员的代码。类似这样:

AmbientColor = setMat.ambientColor;

接着定位到AmbientColor的定义。会发现这是一个属性类型的对象,在它的set块中,又调用了SetValue的私有函数,再定位到这个SetValue函数中,就会发现在该函数中实际调用了EffectParameterSetValue函数;另一方面,函数还会保存设置的值留给以后使用。

自此,我们了解到,Matrial中的参数设置最后将转化为HLSL代码中的参数。可见材质作为特效系统中的一部分,他的各种功能的实现与整个特效系统密切相关。由这一点联想出去,还可以获得不少更深层次的理解。比如材质这个概念来源于计算机图形学,反映了图形学在对真实自然界的表达问题的问题域上的一种划分方式,是与图形学中的各个算法以及图形学的发展过程紧密联系的;设计特效系统的时候,要站在整个图形学理论的高度来进行分析,这样更复杂的材质和更高级的特效才能够被合理的表现出来;材质概念的出现,体现了人类思维的从全局抽象出部分的思维方式。从这样一种思维方向来考虑,我们还可以在材质系统中抽象出其他的参数集合。例如对环境特征的描述,对空间介质特性的描述等等。

想像力是无边无际的,我这里就全当抛砖引玉了。我想,哪一天在读者设计自己游戏中的特效系统的时候,脑中也会多出一条思路来吧!

 

文件九:PlaneRenderer.cs

PlaneRenderer类的注释告诉我们这个类被用于一些单元测试,特别是测试物理引擎。但实际上这个类最后被用来绘制城市的地面。读者可以定位到landscape类中的Render函数。将“cityPlane.Render();”一行除去。开着你的爱车,来到第一个赛道的一片小城区中,比较一下前后的差别。

PlaneRenderer.cs全文不到100行,构造函数之外也仅有两个函数,可以说是本节中见到的最袖珍的文件了。因为想要绘制的面是有大小的面片,而不是数学中所指的无穷大的面,所以在Variables区间中,除了一个表示数学意义上的Plane类型的对象plane外,还有表示面片中心位置的pos,表示面片材质特性的material,表示面片大小的size,(可见这个类只支持正方形面片了。)以及决定在面片上贴图的数量的一个参数Tiling。(仔细观察城市中的地面,会发现它上面的贴图是一块一块拼出来的。)

下面主要注意下DrawPlaneVertices函数中是怎样设置面的顶点数据的。首先是这几行:

Vector3 up = plane.Normal;

if (up.Length() == 0)

      up = new Vector3( 0, 0, 1 );

Vector3 helperVec = Vector3.Cross( up, new Vector3( 1, 0, 0 ) );

if (helperVec.Length() == 0)

      helperVec = new Vector3( 0, 1, 0 );

Vector3 right = Vector3.Cross( helperVec, up );

Vector3 dir = Vector3.Cross( up, right );

float dist = plane.D;

正如注释所说,这几行的目的是为了计算初始化这个面所要用到的向量。而那几行判断语句的作用是为了防止在输入的向量或是两向量的差积(Cross)的结果中出现零向量。

计算得到的结果是三个向量:up表示面的法向量,right表示这个面的切向量,而dir表示面的副法向量。另外也从plane结构中获得了世界坐标原点到面的距离dist

接下来便是初始化顶点的坐标值了。可以看到,这里使用的顶点结构,正是前面讨论过的TangentVertex。可以看到,顶点的位置通过顶点到面片中心的坐标与面片的中心到世界坐标系原点的距离相加得到;顶点的贴图坐标,则是sizeTiling的商,这样Tiling值越大,顶点的贴图坐标就会越小,相应的贴图就会拉升的越开;而upright两个参数则分别是前文中提到的用于法向凹凸贴图的法向量和切向量了。

接着函数调用Device.DrawUserPrimitives函数来对这四个顶点进行渲染。注意PrimitiveType选择的是TriangleStrip类型,而primitveCount参数传入2。这样渲染的实际上是两个三角形面。

 

文件十:Model.cs

该文件负责管理模型的导入和绘制。文件长度与上一个文件相比反差很大,共有800多行。通过行号我们可以看到,Model类的构造函数占据了约150行;Render函数占据了约110行,而Render car函数则占据了约290行之多。造成文件过长的原因之一是Benjamin试图用这一个文件包含所有模型的绘制方法。而不同类型的模型在构造方法上有一定的差别,在绘制过程中同样有各异的方式。于是在整个文件的四处都散布着区分当前模型实例类型的代码。而RenderCar函数的参数中,还包含着一个shadowCarMode的布尔类型的参数。竟然通过这个参数,将两段几乎没有任何耦合性的代码整合到了一起,造就了这个290行的超级函数。据此,我不得不认为,该文件在结构的组织上存在着一定的问题。

首先,没有必要在Model类的构造函数中检查当前实例的类型,然后相应设置不同的模型缩放系数等等。而完全可以将模型缩放系数之类的参数的初始化责任交给调用方。然后再设计一个管理类来负责各种不同模型参数的设置。这样每个文件的长度都会在一个更容易接受的范围内。

其次,当不同的模型有着各异并且无法统一的绘制过程时,最好是通过继承基类或者接口的方式来分离代码。然后用一个工厂类管理物体的创建。(工厂设计模式)这样不仅单个文件的长度被显著的减少,而且会在可扩展性上取得的显著的提高。(想像一下如果我们打算在游戏中加入一个火箭来为游戏添加破坏性元素,而原有Model类中并不支持带火焰的模型的绘制。我们又要将这个文件扩充到多少行?)

撇开这些结构因素不谈,通过这个文件我们还是可以学到很多导入模型和绘制模型的一些细节问题。

首先注意到Variables区间有一个布尔型成员hasAlpha。该变量用来记载当前模型或者模型的某个部件是否透明。同时在Matrial类中也可以找到类似的属性。一般而言,一个物体是否透明决定了两点:一是在Device的设置中的背部裁剪模式(CullMode),透明物体的背面应该是可见的,所以应该将CullMode设置为None;另一方面就是物体阴影的绘制,这一点上透明物体显然比非透明物体复杂得多。在Model类的UseShadow函数中,最开始的一行便是判断当前物体是否为透明体,若是,就不使用阴影,直接返回。

然后我们看看Model构造函数中几个比较关键的地方。由于一个Model是由一个或多个ModelMesh组成的。所以构造函数在通过Content.Load导入模型后,先遍历了模型中的每一个ModelMesh

for (int meshNum = 0; meshNum < xnaModel.Meshes.Count; meshNum++)

接下来函数有对当前Mesh中的每个Effects进行了一次遍历:

for (int effectNum = 0; effectNum < mesh.Effects.Count; effectNum++)

这次遍历的目的是为了用一个被称为cachedEffectParametersEffectParametersList来储存Effect中的参数。cache本意是指计算机主板上的缓存。所以这个List类型的作用从意思上理解就是存储下这些Effect参数了。查找对cachedEffectParameters的引用,会发现除此处之外,只在RenderCar函数中使用了这个成员。作用实际上就是在每一帧绘制前更新特效文件中的一部分设置。

接下来的一段内容用来为当前的Mesh制定一个Technique。但我们下一节讲到Shaders目录中的时候自然会对这些有更清晰的了解。现在不必细究。

让我们来看构造函数中的最后一部分,Add all mesh parts注释的下方。可以看到,一个ModelMesh又是由若干ModelMeshPart组成的。造成这种现象的原因是用3D建模软件构建出的模型中的一个Mesh可能被指定不同的材质。在游戏中,我们固然也要对此信息进行处理,在游戏中也渲染出不同的材质效果。

让我们关注这一行代码:

renderableMeshes.Add(

       part,BaseGame.MeshRenderManager.Add(mesh.VertexBuffer, mesh.IndexBuffer, part, part.Effect ) );

renderableMeshes的定义如下:

Dictionary<ModelMeshPart, MeshRenderManager.RenderableMesh>

这里,实际上是这个文件中最重要的一处。Benjamin为了优化模型的渲染过程,实际上设计了一个MeshRenderManager类来统筹场景中所有Mesh的渲染。在这个MeshRenderManager.Add函数中,传入的ModelMeshPart会按采用的特效名称、采用的Technique以及从特效设置中提取的材质信息进行分类。这样能够将具有同样设置的ModelMeshPart进行集中渲染,显著提高了整体的渲染效率。

MeshRenderManagerAdd函数的返回值是RenderableMesh。这样,Model的类通过renderableMeshes成员也保留了MeshRenderManager中要渲染的Mesh的一个引用。这样,Model在其Render函数中也能够对渲染过程进行一定的控制了。这确实是一个很巧妙的设计

接下来就让我们看看Render函数。

函数中的第一步是判断该物体离摄像机的距离,如果距离太远就不必绘制该物体。而第二步检查物体是否在摄像机的背面,在背面并且相隔一定的距离的物体也不需要绘制。这两个判断是对绘制过程的两个重要的优化,3D游戏中这样的优化往往是必须的。

接下来的任务就是计算每一个将要渲染的ModelMeshPart的世界变换矩阵。第一步是计算整个Model的世界变换矩阵:

renderMatrix = objectMatrix * renderMatrix;

接下来遍历每一个ModelMesh,计算Mesh的世界变换矩阵:

Matrix worldMatrix = transforms[mesh.ParentBone.Index] * renderMatrix;

最后遍历每一个ModelMeshPart。通过renderableMeshes获得MeshRenderManager中的对应RenderableMesh对象,并在其renderMatrices中添加这个Mesh的世界变换矩阵。这个过程相当于在MeshRenderManager中注册了这个ModelMeshPart的一个实例。MeshRenderManager会在渲染时遍历这个renderMatrices,并在由此指定的位置绘制出一个该ModelMeshPart,而在绘制结束后清空这个renderMatrices,为下一帧做准备。我们会在这个文件叙述完后查看MeshRenderManager.cs文件。那时便可以看到以上所说的过程。

Model类中还有比较重要两个函数:RenderCarUseShadow函数,当中涉及了阴影的渲染。为了让讨论这个文件的篇幅不像文件本身那么长。我们将来下一节再回过头来分析他们。

总体来说,这个Model类在设计上既有不足之处,也有很值得我们学习的地方。如果我们能在自己的设计中改进他的不足,发扬他的优点,就能设计出一个完善的模型管理组件。如果再加入对骨骼动画等功能的支持,这个部分就会显得相当专业了。话又说回来,飞车游戏对模型的要求毕竟不算太高,相信也正是出于这个原因Benjamin才用这样区区一个文件来管理所有的模型相关问题。在一定程度上也是可以理解的。所以还是以IT行业应有的包容心态来看待这里的一点点不足吧。

 

文件十一:MeshRenderManager.cs

上一个文件中对MeshRenderManager类的作用已经说的比较清楚了。所以我们在此主要看看类方法的具体实现方式。

MeshRenderManager类中又定义了3个私有类。分别是:RenderableMeshMeshesPerMaterialMeshesPerMaterialPerTechniques。从名字上便可以看出三个类是以一种层次结构来组织的。具体来说,MeshesPerMaterial包含了某个特定材质的RenderableMesh,而MeshesPerMaterialPerTechniques又包含了应用某个特定Technique的所有MeshesPerMaterial。他们3者实际上在MeshRenderManager类中组成了一个树结构。

让我们从MeshRenderManagerRender函数着手。飞车游戏中所有模型的绘制都在这里。

或许第一次展开Render函数的时候你会跟我一样惊讶为什么这个函数会如此的短。能够写这么短的原因是代码被那3个私有类分担了。在这个Render函数中,只是设置了对所有模型都适用的渲染状态,例如使能深度缓冲,设置法向贴图特效等。接着遍历类中每一个MeshesPerMaterialPerTechniques。调用每一个该对象的Render函数。

在这里我实在忍不住要插一句嘴。解释一个大家可能也早就发现了的问题:Benjamin从来不用foreach语句!难道for循环效率比foreach高吗?我们是否也要学着他,以后写循环的时候都使用原始的for呢?这个问题还是有必要在此说明一下的。

事实上,在EffectiveC#一书中,已经对这个问题做出了很好的解释。该书的“条款11:优先采用foreach循环语句”中明确的说:“foreach语句会为我们的集合产生最好的集合代码,对于一些特殊的集合类型,C#编译器会产生具有最佳效率的代码。遍历集合时,我们应该使用foreach语句,而非其他的循环结构。”我想这句话对这个问题的解释已经基本足够了。

回归正题,让我们定位到MeshesPerMaterialPerTechniques类的Render函数。

在这个层次中,设置了这个Effect使用的Technique。调用了effect.Begin函数,由于绘制模型的特效文件的Technique中都只有一个Pass,所以也仅仅只需要对一个pass调用Begin函数。接着遍历这个类实例中的所有MeshesPerMaterial对象,将渲染任务继续传递了下去。

接着在MeshesPerMaterial类的Render函数中,使用该示例中的Material来初始化当前的Effect的参数;设置设备的顶点格式、AlphaTestCullMode等参数。接着调用RenderableMeshRender函数。

对模型的实际渲染过程就在RenderableMesh类中了。还记得在介绍上一个文件Model.cs的时候提到过一个叫做renderMatrices的矩阵表吗?在游戏的每一帧中的更新中,会将需要绘制的模型(模型部分)的位置变换矩阵添加到相应的renderMatrices中。然后此处的RenderableMeshRender函数中遍历了这个renderMatrices成员。对每一个变换矩阵执行了一次RenderMesh私有方法后。又将renderMatrices清空。以准备下一帧的重新注册。

而这个RenderMesh函数也十分小巧。设置完世界转换坐标后,对Effect的所有参数的设置都已经就位了。调用了Effect.CommitChanges来使设置更改生效。然后将当前Mesh的顶点缓冲区和索引缓冲区传递给Device。接下来就只剩最后一步,告诉设备,你可以开始画了。

看完这些,你是否和我一样,从内心中涌上来一阵感动呢?一个看似很复杂的问题,通过了合理的组织和分解,将代码分散到多个不同的层面,被很体面地解决了。因为结构很合理,每一个层面上的代码均在二三十行左右,也使程序的可读性得以大大提高。这确实是我们应该借鉴的地方啊。

MeshRenderManager类中除了Render外的另一个重要的函数便是Add函数。在Model类的构造函数中将调用这个函数将模型的每一个部件注册到MeshRenderManager中。MeshRenderManager类中能有如此良好的组织结构也是得益于这个函数。函数的主要处理过程分为两步,第一步是判断传入的模型部件应属于树结构的那一个分支;第二步是检查该分支是否已经存在。如果存在,将该模型部件添加到相应位置中,若不存在,则创建该分支并添加模型部件。听上来很简单不是吗?那么就让我偷个懒吧,具体的代码留给读者自己去分析了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值