40.天空盒和天空球(v0.5)
2011 年 4 月初,趁学生期中考试期间的些许闲暇,花了近一个半月的时间将 StunEngine 从 0.4 版本升级到了 0.5,使用的 API 也从 XNA3.1 迁移到了 XNA4.0,而XNA4.0针对DX10和WP7进行了较大的改动,具体变化的内容可参见 XNA 的帮助文件或 Shawn Hargreaves 博客中的 Breaking changes in XNA Game Studio 4.0 和 XNA 3.1 to XNA 4.0 Cheat Sheet,很多东西都需要重新写过,对于 StunEngine 引擎来说,主要的难点有三:
难点1:XNA4.0 取消了 EffectPool,这意味着在 effect 文件中不支持shared参数,而在StunEngine0.4中主要使用的共享参数主要有投影矩阵、雾化颜色、雾化距离、环境光颜色、光源参数数组等,这些参数只需在游戏循环中设置一次,就可以改变每个对象的effect中的这个参数,但现在这个方法不可用,虽然不去掉shared修饰符HLSL编译器并不会报错,但实际上编译时会忽略掉它。解决方法在下面几篇文章会提到。
难点2:取消了硬件剪裁,即取消了图形设备的Device.ClipPlanes[n].Plane属性,Shawn Hargreaves解释这样做的原因是DirectX 10之后不再支持硬件剪裁,而且近十几年以来的趋势就是将固定管道转移到可编程流水线中(例如DX10中的alpha测试,SM3.0中的雾化计算,更早些年中的光照计算和纹理设置等)。这样水面的实现就要换另一种方式实现,答案是将剪裁代码放在Effect文件中,这个方法主要参考了《3D Graphics with XNA Game Studio 4.0》(本网站有电子书和源代码下载)第5章中的水面示例,官网的 How to replace ClipPlanes[n].Plane in XNA 4.0 也非常有参考价值。
难点3:因为与 RenderTarget2D 功能类似,所以 XNA4.0 取消了 ResolveBackBuffer 和 ResolveTexture2D,详情可参见 Shawn Hargreaves 博客中的 ResolveBackBuffer and ResolveTexture2D in XNA Game Studio 4.0。这样就无法直接从后备缓冲中获取屏幕截图,而且 RenderTarget2D 现在是从 Texture2D 类继承的,所以也无需拥有 RenderTarget2D.GetTexture() 方法,这会给 StunEngine 引擎的后期处理类带来一定的麻烦,具体解决方法会在以后的后期处理类 PostProcessor 中讨论。
在引擎升级过程中最费时间的就是 Effect 系统,幸运的是,官网上有一个非常好的例子 Stock Effects示例,它实际上是开放了 XNA 系统内置的BasicEffect、SkinnedEffect、EnvironmentMapEffect、DualTextureEffect、AlphaTestEffect、SpriteBatch 的源代码,从这个示例中我学到了很多东西,强烈推荐去研究一下它的实现思路,本引擎新的 Effect 系统主要就是参考这个示例。
简而言之,每个 Effect 系统都是由三个板块构成的,以后的文章都会按照这个思路进行介绍。
首先讨论天空盒,因为它无需处理光照,顶点也只有 8 个,最为简单。
第一步:创建Effect文件
首先是编写天空盒使用的Effect文件,先贴上代码:
float4x4 WorldViewProj : WORLDVIEWPROJECTION; // 世界矩阵、视矩阵、投影矩阵的组合矩阵 // 天空盒使用的立方纹理 texture CubeTexture; // 立方纹理采样器 sampler CubeTextureSampler = sampler_state { texture = <CubeTexture>; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = clamp; AddressV = clamp; }; bool ClipPlaneEnabled = false; // 是否开启剪裁,默认为false float4 ClipPlane; // 剪裁平面 // 天空盒使用的顶点着色器输出结构 struct SkyBoxVSOut { float4 PositionPS : SV_Position; float3 Pos3D : TEXCOORD0; }; SkyBoxVSOut SkyBoxVS( float4 Position : SV_Position) { SkyBoxVSOut vout; vout.PositionPS = mul(Position, WorldViewProj); vout.Pos3D = Position; return vout; } float4 SkyBoxPS(float3 Pos3D: TEXCOORD0): SV_Target0 { if(ClipPlaneEnabled) clip(dot(float4(Pos3D, 1), ClipPlane)); return texCUBE(CubeTextureSampler, Pos3D); } technique SkyBox { pass Pass0 { VertexShader = compile vs_2_0 SkyBoxVS(); PixelShader = compile ps_2_0 SkyBoxPS(); } }
代码很短,其中的原理大部分已经在28.天空盒SkyBoxSceneNode类中的介绍过了,但有两点需要额外说明:
1.在Effect文件中并没有像网上的许多示例那样包含世界矩阵World、视矩阵View和投影矩阵Projection,而是只包含一个这三者的组合矩阵WorldViewProj,而这个组合矩阵是在XNA程序中预先算好(具体计算过程会在后面的第二步中提到)传递到shader中的。如果你稍微学习过一点顶点着色器程序,你会发现绝大部分Effect文件的顶点着色器的开始代码都是相同的:即将顶点的本地坐标转换到世界坐标,典型代码如下:
float4x4 ViewProjection = mul (View, Projection); float4x4 WorldViewProjection = mul(World, ViewProjection); Output.Position =mul(inPos,WorldViewProjection);
GPU会对每个顶点进行同样的操作,如果一个模型的顶点数量很多,计算量是很大的。但情况并非如此,HLSL编译器挺聪明的,它会在编译时自动将对所有顶点来说都相同的操作提取出来,在顶点着色器程序之前将这些操作在CPU中进行,这种操作称之为preshader,它可以提高性能,避免不必要的运算,想了解具体详情,可以参考一下Riemers的Cleaning up our interface using Preshaders(http://www.riemers.net/eng/Tutorials/XNA/Csharp/Series3/Preshaders.php)一文。
但是在Stock Effects示例中也提到preshader的缺点:
- HLSL编译器并不总能找到最优化的方法。
- 计算preshaders的虚拟机效率并不是最高。
- 在Xbox 360或Windows Phone上不支持Preshaders。
因此它使用的是直接传递WorldViewProj矩阵,然后使用一行代码实现上述的三行代码同样的顶点变换,不过使用这种做法CPU就要负担较大的计算量。
vout.PositionPS = mul(Position, WorldViewProj);
2.相对于StunEngine0.4,天空盒的Effect文件额外多了两个参数:布尔类型的ClipPlaneEnabled和float4类型的ClipPlane,在像素着色器程序的开头多了两行代码:
if(ClipPlaneEnabled) clip(dot(float4(Pos3D, 1), ClipPlane));
这就是解决剪裁平面问题的方法,在一开始的难点2中我就提到,XNA4.0中取消了硬件剪裁,要实现水面的反射和折射,剪裁的操作必须在shader中实现,解决方法很简单。关键就是Effect的内置指令clip,clip只能用在pixel shader中,用法是clip(x),其中的参数x小于0,则就将这个像素剪裁。
例如剪裁平面ClipPlane为float4(0,1,0,-2),这个float4的前三个分量表示法线方向,第四个分量表示离开坐标原点的负距离,因此一个float4就可以唯一确定一个平面(具体解释在XNA帮助文件中就有,你可以自己找一下),float4(0,1,0,-2)即表示y=2的正面向上平面。比方说有一个点它的世界坐标为(3,4,5),即它在y=2平面之上,那么dot(float4(Pos3D, 1), ClipPlane)=dot(float4(3,4,5,1),float4(0,1,0,-2))=3*0+4*1+5*0+1*-2=2,因为点乘的结果大于0,所有不会被剪裁,同理同样的点在ClipPlane= float4(0,-1,0,2)(表示y=2正面向下的平面)时就会被剪裁。
第二步:创建Effect文件的封装类
一开始学习XNA时,在Draw代码中最常见的代码是:
effect.Parameters["World"].SetValue(worldMatrix); effect.Parameters["View"].SetValue(viewMatrix); effect.Parameters["Projection"].SetValue(projectMatrix); effect.Parameters["lightDirection"].SetValue(lightDirection); effect. Parameters["Texture"].SetValue(texture);
即每次调用绘制过程时都要设置effect的参数,这样做的缺点主要有两个:
1.无法进行类型检查,比方说将一个float类型的参数设置给effect的World参数,XNA程序是不会报错的,只有生成程序后才会报错让你意识到参数类型有误。
2.SetValue方法比较耗费资源,如无必要就不应该调用,上述代码中的第4、5个参数在程序运行过程中往往是不变的,就没有必要在Draw方法中每帧进行设置。 当你使用XNA框架内置的BasicEffect时,发现它并不是直接设置的,它的代码是这样的:
basicEffect.World = worldMatrix; basicEffect.View = viewMatrix; basicEffect.Projection = projectMatrix;
这样做的原因是BasicEffect实际上是effect的一个封装类。 在Stock Effects示例中提到:在设计effect参数时,一个非常重要的事情就是需要提供一个清晰的API,最终的参数格式并不总是最有效率的。使用effect封装类将API所需的属性暴露,而无需将这些属性匹配HLSL的底层shader参数。当程序员改变了一个属性时,我们只需设置一个dirty标志,然后在OnApply过程中重新计算HLSL参数。
因此在StunEngine.Effects命名空间新建一个类SkyBoxEffect,它继承自XNA引擎内建的Effect类,位于Effects文件夹下。具体代码如下:
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using StunEngine.Rendering; namespace StunEngine.Effects { ///
public class SkyBoxEffect : Effect { // Effect参数 EffectParameter worldViewProjParam; EffectParameter cubeTextureParam; EffectParameter clipPlaneParam; EffectParameter clipPlaneEnabledParam; // 字段 Matrix world = Matrix.Identity; Matrix view = Matrix.Identity; Matrix projection = Matrix.Identity; Vector3 eyePosition=Vector3 .Zero ; bool clipPlaneEnabled = false; EffectDirtyFlags dirtyFlags = EffectDirtyFlags.All; ///
public Matrix World { get { return world; } set { world = value; dirtyFlags |= EffectDirtyFlags.WorldViewProj; } } ///
public Matrix View { get { return view; } set { view = value; dirtyFlags |= EffectDirtyFlags.WorldViewProj; } } ///
public Matrix Projection { get { return projection; } set { projection = value; dirtyFlags |= EffectDirtyFlags.WorldViewProj; } } ///
public Vector3 EyePosition { get { return eyePosition; } set { eyePosition = value; dirtyFlags |= EffectDirtyFlags.WorldViewProj; } } ///
public TextureCube CubeTexture { get { return cubeTextureParam.GetValueTextureCube(); } set { cubeTextureParam.SetValue(value); } } ///
public Vector4 ClipPlane { get { return clipPlaneParam.GetValueVector4(); } set { if (value == Vector4.Zero) clipPlaneEnabled = false; else clipPlaneEnabled = true; clipPlaneEnabledParam.SetValue(clipPlaneEnabled); clipPlaneParam.SetValue (value); } } ///
public SkyBoxEffect(GraphicsDevice device) : base(device, Resource.SkyBoxEffectBin) { CacheEffectParameters(null); } ///
protected SkyBoxEffect(SkyBoxEffect cloneSource) : base(cloneSource) { CacheEffectParameters(cloneSource); world = cloneSource.world; view = cloneSource.view; projection = cloneSource.projection; eyePosition =cloneSource .eyePosition ; clipPlaneEnabled = cloneSource.clipPlaneEnabled; } ///
public override Effect Clone() { return new SkyBoxEffect(this); } ///
void CacheEffectParameters(SkyBoxEffect cloneSource) { worldViewProjParam = Parameters["WorldViewProj"]; cubeTextureParam = Parameters["CubeTexture"]; clipPlaneParam = Parameters["ClipPlane"]; clipPlaneEnabledParam = Parameters["ClipPlaneEnabled"]; } ///
protected override void OnApply() { // 重新计算WorldViewProj组合矩阵 if ((dirtyFlags & EffectDirtyFlags.WorldViewProj) != 0) { world = Matrix.CreateTranslation (eyePosition); Matrix worldView=Matrix.Multiply(world, view); Matrix worldViewProj=Matrix.Multiply(worldView, projection); worldViewProjParam.SetValue(worldViewProj); dirtyFlags &= ~EffectDirtyFlags.WorldViewProj; } } } }
由以上代码可知,封装类SkyBoxEffect的属性并不需要和Effect文件一一对应,SkyBoxEffect的属性有World、View、Projection、EyePosition、CubeTexture和ClipPlane共6个,而SkyBox.fx的参数只有WorldViewProj、CubeTexture、ClipPlaneEnabled、ClipPlane4个,通过XNA程序直接设置SkyBoxEffect类的属性,而SkyBoxEffect类会进行一定的预处理才设置effect的参数,设置操作是在OnApply()方法中进行的,Effect.OnApply方法在EffectPass.Apply方法前被调用。在天空盒的OnApply()方法中进行的操作很简单,就是将世界矩阵始终和相机位置对齐(使人产生一种天空盒永远不动的错觉),然后计算出三者的组合矩阵并将这个矩阵传递到effect中。
在原始的Stock Effects示例中并没有EyePosition这个属性,因为相机位置就是视矩阵View的逆矩阵的M41、M42、M43分量,是可以算出的,代码为:
Matrix viewInverse; Matrix.Invert(ref view, out viewInverse); eyePositionParam.SetValue(viewInverse.Translation);
但是因为我的引擎在相机类中已经计算了相机位置,所以直接作为参数传入可以减少计算量。
位标志EffectDirtyFlags枚举的定义EffectHelpers.cs文件中,它的完整代码如下:
using System; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; namespace StunEngine.Effects { ///
[Flags] public enum EffectDirtyFlags { WorldViewProj = 1, World = 2, EyePosition = 4, MaterialColor = 8, Fog = 16, FogEnable = 32, AlphaTest = 64, ShaderIndex = 128, All = -1 } ///
internal static class EffectHelpers { [..] } }
EffectHelpers的代码在天空盒中并没有用到,后面的Effect封装类才会用到,所以暂时省略。
如果你不理解使用位标志的目的和使用方法,请先看一下XNA中的位标志一文。
封装类的基类是Effect,它的构造函数为:
public Effect(GraphicsDevice graphicsDevice, byte[] effectCode);
即需要以二进制字节数组形式的effect代码作为参数,要生成二进制代码形式的effect代码,需要借助一个命令行程序CompileEffect,它就包含在Stock Effects示例中。我的做法是将这个项目复制到我自己的解决方案中,截图如下:
然后需要右击StunEngine项目,在弹出菜单中选择最下面一个“属性”,然后在“生成事件”选项卡中点击“编辑预先生成事件”按钮,在弹出的“预先生成事件命令行”对话框中输入如下代码:
$(SolutionDir)CompileEffect\bin\$(ConfigurationName)\ CompileEffect.exe Windows HiDef $(SolutionDir)StunEngineContent\Effects\SkyBox.fx $(ProjectDir)HLSLBin\SkyBoxEffect.bin
以上代码的意思是使用$(SolutionDir)CompileEffect\bin\$(ConfigurationName)\目录下的CompileEffect.exe将$(SolutionDir)StunEngineContent\Effects\目录下的SkyBox.fx文件编译成二进制文件放置在$(ProjectDir)HLSLBin\目录下,名称为SkyBoxEffect.bin,使用的配置为Windows HiDef。
这样在每次生成StunEngine时都会预先生成Effect的二进制文件,从截图中还可以看到以后的许多Effect我也进行了同样的操作,包括天空球、水面、海面、后期处理、BillBoard和绘制3D线段的LineRendering.fx。它们都放置在引擎根目录下的HLSLBin目录中。
最后将这些二进制Effect文件放置到资源Resource.resx中,这样就可以通过使用Resource.SkyBoxEffectBin访问到这个二进制文件,而且是嵌入到引擎的dll文件中的。
第三步:创建SceneNode类
对应天空盒的SceneNode类是位于目录SceneNodes→Environment下的SkyBoxSceneNode.cs文件,代码如下:
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StunEngine.Effects; using StunEngine.Rendering; using StunEngine.SceneManagement; namespace StunEngine.SceneNodes { ///
public class SkyBoxSceneNode : Renderable3DSceneNode { ///
Mesh mesh; ///
SkyBoxEffect effect; ///
public SkyBoxEffect Effect { get { return effect; } } ///
///图形设备 ///内容管理器 ///所属场景 ///天空盒纹理名称,需包含路径 public SkyBoxSceneNode(GraphicsDevice graphicsDevice, ContentManager setContent,Scene setScene,string setSkyboxTextureName) : base(graphicsDevice,setContent,setScene) { effect = new SkyBoxEffect(graphicsDevice ); if(!string.IsNullOrEmpty (setSkyboxTextureName )) effect .CubeTexture =content .Load <TextureCube>(setSkyboxTextureName ); //不对天空盒进行剔除操作 this.DisableCulling = true; this.DisableUpdateCulling = true; } ///
///剪裁平面 public override void SetClipPlane(Vector4? Plane) { effect.ClipPlane = Plane.Value; } ///
public override void Initialize() { base.Initialize(); this.UpdateOrder = SceneNodeOrdering.EnvironmentMap.GetValue(); //创建天空盒顶点数据 this.mesh = MeshBuilder.CreateSkybox(graphicsDevice); } ///
///时间变量 ///相机视矩阵 ///相机投影矩阵 ///相机位置 ///
上述代码与0.4版本的区别不大,主要的变化有:
1.用SkyBoxEffect替代了以前的材质类SkyBoxMaterial,因为两者的作用本质上是一样的(其实大部分代码也是相同的),即作为XNA程序和Effect文件之间的桥梁,而且我还完全移除了所有材质类(这是在19.Material类中介绍的),材质类都是用effect封装类代替了,两者的作用是一样的。
2.构造函数的参数中取消了对引擎的引用,取而代之的是图形设备和内容管理器两个参数,这样可以减少对引擎类的耦合,今后若对引擎类有较大的变动也不会对SceneNode类带来较大的影响。
3.Draw()方法的参数多了相机的视矩阵、投影矩阵和相机位置,在方法中通过effect包装类设置effect的相关参数。
单元测试位于StunEngine0.5Test项目的TestGame.cs文件的TestSkyBoxSceneNode()方法中。
天空球
天空球的原理与天空盒是类似的,我就偷懒不写了,自己看源代码吧。基本原理可参见29.天空球SkyDomeSceneNode类,Effect文件为StunEngineContent项目中Effects目录下的SkyDome.fx,二进制文件在资源中的名称为SkyDomeEffectBin,effect封装类为StunEngine项目中Effects目录下的SkyDomeEffect.cs,SceneNodel类为SceneNodes→Environment目录下的SkyDomeSceneNode.cs,单元测试位于StunEngine0.5Test项目的TestGame.cs文件的TestSkyDomeSceneNode()方法中。
文件下载(已下载 1673 次)发布时间:2011/5/19 上午8:01:43 阅读次数:10534