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.0XNA 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的缺点:

因此它使用的是直接传递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  阅读次数:10534

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

沪 ICP 备 18037240 号-1

沪公网安备 31011002002865 号