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(http://blogs.msdn.com/b/shawnhar/archive/2010/03/16/breaking-changes-in-xna-game-studio-4-0.aspx)和 XNA 3.1 to XNA 4.0 Cheat Sheet(http://www.nelxon.com/blog/xna-3-1-to-xna-4-0-cheatsheet/),很多东西都需要重新写过,对于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(http://forums.create.msdn.com/forums/p/56786/347742.aspx)也非常有参考价值。

难点3:因为与RenderTarget2D功能类似,所以XNA4.0取消了ResolveBackBuffer和ResolveTexture2D,详情可参见Shawn Hargreaves博客中的ResolveBackBuffer and ResolveTexture2D in XNA Game Studio 4.0(http://blogs.msdn.com/b/shawnhar/archive/2010/03/30/resolvebackbuffer-and-resolvetexture2d-in-xna-game-studio-4-0.aspx)。这样就无法直接从后备缓冲中获取屏幕截图,而且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的缺点:

因此它使用的是直接传递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
{
    /// 
    /// 对绘制天空盒的SkyBox.fx的封装。
    /// 
    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;

        /// 
        /// 获取或设置世界矩阵。世界矩阵改变时会影响effect的WorldViewProj参数
        /// 
        public Matrix World
        {
            get { return world; }
            set
            {
                world = value;
                dirtyFlags |= EffectDirtyFlags.WorldViewProj;
            }
        }

        /// 
        /// 获取或设置视矩阵,视矩阵改变时会影响effect的WorldViewProj。
        /// 
        public Matrix View
        {
            get { return view; }
            set
            {
                view = value;
                dirtyFlags |= EffectDirtyFlags.WorldViewProj;
            }
        }

        /// 
        /// 获取或设置投影矩阵,,投影矩阵改变时会影响effect的WorldViewProj。
        /// 
        public Matrix Projection
        {
            get { return projection; }
            set
            {
                projection = value;
                dirtyFlags |= EffectDirtyFlags.WorldViewProj;
            }
        }

        /// 
        /// 获取或设置相机位置,相机位置改变时会影响effect的WorldViewProj。
        /// 
        public Vector3 EyePosition
        {
            get { return eyePosition; }
            set
            {
                eyePosition = value;
                dirtyFlags |= EffectDirtyFlags.WorldViewProj;
            }
        }

        /// 
        /// 获取或设置天空盒使用的立方纹理
        /// 
        public TextureCube CubeTexture
        {
            get { return cubeTextureParam.GetValueTextureCube(); }
            set { cubeTextureParam.SetValue(value); }
        }

        /// 
        /// 获取或设置剪裁平面,将这个值设置为Vector.Zero就关闭剪裁
        /// 
        public Vector4 ClipPlane
        {
            get { return clipPlaneParam.GetValueVector4(); }
            set 
            {
                if (value == Vector4.Zero)
                    clipPlaneEnabled = false;
                else
                    clipPlaneEnabled = true;
                clipPlaneEnabledParam.SetValue(clipPlaneEnabled);
                clipPlaneParam.SetValue (value);  
            }
        }

        /// 
        /// 使用默认参数创建一个新SkyBoxEffect对象。
        /// 
        public SkyBoxEffect(GraphicsDevice device)
            : base(device, Resource.SkyBoxEffectBin)
        {
            CacheEffectParameters(null); 
        }

        /// 
        /// 通过从一个已经存在的对象中复制参数创建一个新SkyBoxEffect对象。
        /// 
        protected SkyBoxEffect(SkyBoxEffect cloneSource)
            : base(cloneSource)
        {
            CacheEffectParameters(cloneSource);

            world = cloneSource.world;
            view = cloneSource.view;
            projection = cloneSource.projection;
            eyePosition =cloneSource .eyePosition ;
            clipPlaneEnabled = cloneSource.clipPlaneEnabled;
        }


        /// 
        /// 创建一个当前SkyBoxEffect对象的副本。
        /// 
        public override Effect Clone()
        {
            return new SkyBoxEffect(this);
        }

        /// 
        /// 缓存effect的参数。
        /// 
        void CacheEffectParameters(SkyBoxEffect cloneSource)
        {
            worldViewProjParam = Parameters["WorldViewProj"];
            cubeTextureParam = Parameters["CubeTexture"];
            clipPlaneParam = Parameters["ClipPlane"];
            clipPlaneEnabledParam = Parameters["ClipPlaneEnabled"];
        }


        /// 
        /// 在施加effect前根据需要重新计算effect参数。
        /// 
        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
{
    /// 
    /// 保存在OnApply()方法中需要重新计算的effect参数。
    /// 
    [Flags]
    public enum EffectDirtyFlags
    {
        WorldViewProj = 1,
        World = 2,
        EyePosition = 4,
        MaterialColor = 8,
        Fog = 16,
        FogEnable = 32,
        AlphaTest = 64,
        ShaderIndex = 128,
        All = -1
    }
    
    /// 
    /// 被不同的Effect封装类共享的方法,用于重新计算effect参数。
    /// 
    internal static class EffectHelpers
    {
        [..]
    }
}

EffectHelpers的代码在天空盒中并没有用到,后面的Effect封装类才会用到,所以暂时省略。

如果你不理解使用位标志的目的和使用方法,请先看一下XNA中的位标志一文。

封装类的基类是Effect,它的构造函数为:

public Effect(GraphicsDevice graphicsDevice, byte[] effectCode);

即需要以二进制字节数组形式的effect代码作为参数,要生成二进制代码形式的effect代码,需要借助一个命令行程序CompileEffect,它就包含在Stock Effects示例中。我的做法是将这个项目复制到我自己的解决方案中,截图如下:

导入CompileEffect项目

然后需要右击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。

编译effect文件

这样在每次生成StunEngine时都会预先生成Effect的二进制文件,从截图中还可以看到以后的许多Effect我也进行了同样的操作,包括天空球、水面、海面、后期处理、BillBoard和绘制3D线段的LineRendering.fx。它们都放置在引擎根目录下的HLSLBin目录中。

最后将这些二进制Effect文件放置到资源Resource.resx中,这样就可以通过使用Resource.SkyBoxEffectBin访问到这个二进制文件,而且是嵌入到引擎的dll文件中的。

将编译后的bin文件嵌入到资源中

第三步:创建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 mesh;   
        
        /// 
        /// 天空盒使用的effect
        /// 
        SkyBoxEffect effect;

        /// 
        /// 获取天空盒使用的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);
        }

        /// 
        /// 绘制天空盒。
        /// 
        /// 时间变量
        /// 相机视矩阵
        /// 相机投影矩阵
        /// 相机位置
        /// 绘制的三角形数量,天空盒有六个面,12个三角形。
        public override int Draw(GameTime gameTime, Matrix ViewMatrix, Matrix ProjectionMatrix, Vector3 CameraPosition)
        {
            // 天空盒绘制前需要关闭深度缓冲
            graphicsDevice.DepthStencilState = DepthStencilState.None;
            
            mesh.PrepareRender(graphicsDevice);
            effect.World = Pose.WorldMatrix;
            effect.View = ViewMatrix;
            effect.Projection = ProjectionMatrix;
            effect.EyePosition = CameraPosition;            
            effect.CurrentTechnique .Passes[0].Apply();
            mesh.Draw(graphicsDevice);
            
            // 天空盒绘制结束后别忘了打开深度缓冲
            graphicsDevice.DepthStencilState = DepthStencilState.Default;

            //返回此节点的图元数量
            return 12;
        }
    }
}

上述代码与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  阅读次数:9985

2006 - 2024,推荐分辨率1024*768以上,推荐浏览器Chrome、Edge等现代浏览器,截止2021年12月5日的访问次数:1872万9823 站长邮箱

沪ICP备18037240号-1

沪公网安备 31011002002865号