问题
你想在3D世界中创建一个漂亮的3D爆炸效果。
解决方案
你可以通过混合大量的小火焰图像(叫做粒子)创建一个爆炸效果,,如图3-22所示。注意一个单独的粒子很暗,但是,如果你添加大量的粒子,就会获得一个漂亮的爆炸效果。
图3-22 单个爆炸粒子
在爆炸的开始阶段,所有粒子都位于爆炸的初始位置,因为所有图像的颜色都叠加在了一起,所以会形成一个明亮的火球,绘制粒子时你需要使用additive blending才能获得这个效果。
随着时间流逝,粒子会从初始位置离开。而且,在离开中心的过程中还要使每个粒子图像变得更小更暗(淡出)。
一个漂亮的3D爆炸需要用到50至100个粒子。因为这些图像只是显示在3D世界中的2D图像,需要用到billboard,所以这个教程是建立在教程3-11基础上的。
你将创建一个基于shader的实时粒子系统。每一帧中,计算每个粒子的存活时间(age),它的位置、颜色和大小是基于存活时间计算的。当然,应该在GPU中进行这些计算,让CPU可以完成更重要的工作。
工作原理
如前所述,本章会重用教程3-11的代码,所以需要理解球形billboarding的原理。对每个粒子,你仍需定义六个顶点,而且只需定义一次,将它们传递到显卡。对每个顶点,需要包含下列数据用于vertex shader:
- 爆炸的初始位置的billboard中心点3D位置(用于billboarding) (Vector3 = 三个浮点数)
- 纹理坐标(用于billboarding) (Vector2 = 两个floats)
- 粒子创建的时间(一个浮点数)
- 粒子的存活时间(一个浮点数)
- 粒子移动的方向(Vector3 = 三个浮点数)
- 一个随机数,用于给每个粒子施加不同的行为(一个浮点数)
从这些数据,GPU可以基于当前时间计算所需的所有东西。例如,它可以计算粒子已经存活了多少时间。当你将这个存活时间乘以粒子的方向时,就可以获取粒子离开爆炸初始位置的距离。
你需要一个顶点格式存储这个数据,使用一个Vector3存储位置;一个Vector4存储第二,第三,第四个数据;另一个Vector4存储最后两个数据。创建自定义顶点格式可参见教程5-14:
public struct VertexExplosion { public Vector3 Position; public Vector4 TexCoord; public Vector4 AdditionalInfo; public VertexExplosion(Vector3 Position, Vector4 TexCoord, Vector4 AdditionalInfo) { this.Position = Position; this.TexCoord = TexCoord; this.AdditionalInfo = AdditionalInfo; } public static readonly VertexElement[] VertexElements = new VertexElement[] { new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0), new VertexElement(0, 12, VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0), new VertexElement(0, 28, VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 1), }; public static readonly int SizeInBytes = sizeof(float) * (3 + 4 + 4); }
这看起来与教程3-11中的很像,除了用一个Vector4代替Vector2作为第二个参数,让额外的两个浮点数可以传递到vertex shader。要创建随机方向,需要使用一个randomizer,所以在XNA类中添加以下变量:
Random rand;
在Game类的Initialize方法中进行初始化:
rand = new Random();
现在可以创建顶点了。下面的方法基于教程3-11生成顶点:
private void CreateExplosionVertices(float time) { int particles = 80; explosionVertices = new VertexExplosion[particles * 6]; int i = 0; for (int partnr = 0; partnr < particles; partnr++) { Vector3 startingPos = new Vector3(5,0,0); float r1 = (float)rand.NextDouble() - 0.5f; float r2 = (float)rand.NextDouble() - 0.5f; float r3 = (float)rand.NextDouble() - 0.5f; Vector3 moveDirection = new Vector3(r1, r2, r3); moveDirection.Normalize(); float r4 = (float)rand.NextDouble(); r4 = r4 / 4.0f * 3.0f + 0.25f; explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection,r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 0, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 1, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); } }
这个方法需要在初始化一个新爆炸时调用,它接受当前时间为参数。首先定义了爆炸中使用的粒子数量,创建了一个数组保存每个粒子的六个顶点。然后,创建这些顶点。对每个顶点,首先储存startingPos,即爆炸的中心点。
然后,你想让每个粒子都有不同的方向。这可以通过生成两个[0,1]区间的随机数实现,但需要减去0.5使它们落在[–0.5f, +0.5f]区间。基于这个随机值创建一个Vector3,归一化这个Vector3让随机方向具有相同的长度。
技巧:推荐使用归一化,因为向量(0.5f, 0.5f, 0.5f)比向量(0.5f, 0, 0)长。所以,当你增加第一个向量的位置时,如果使用第二个向量作为运动方向,粒子会运动得更快。结果是,爆炸会变得像个立方体而不是球形。
然后,获取另一个随机值,用来给每个粒子施加各自的效果,这是因为粒子的速度和大小都要由这个值进行调整。要求有一个介于0和1之间的随机数,然后缩放到[0.25f, 1.0f]区间(谁想要一个速度为的粒子?)。
最后,将每个粒子的六个顶点添加到顶点数组中。注意每六个顶点都包含相同的信息,除了纹理坐标,纹理坐标用来在vertex shader定义每个顶点偏离中心位置的偏移量。
HLSL
现在准备好了顶点,可以编写vertex和pixel shader了。
首先定义XNA-HLSL变量,纹理采样器,vertex shader和pixel shader的输出结构:
//XNA interface float4x4 xView; float4x4 xProjection; float4x4 xWorld; float3 xCamPos; float3 xCamUp; float xTime; //Texture Samplers Texture xExplosionTexture; sampler textureSampler = sampler_state { texture = <xExplosionTexture>; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = CLAMP; AddressV = CLAMP; }; struct ExpVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; float4 Color : COLOR0; }; struct ExpPixelToFrame { float4 Color : COLOR0; };
因为爆炸中的每个粒子都要施加billboard,你需要相同的变量。这次,你还需要在XNA程序的xTime变量中存储当前时间,让vertex shader可以计算每个粒子的生存时间。
如教程3-11所述,vertex shader将2D屏幕位置和纹理坐标传递到pixel shader中。因为你想实现粒子的淡出效果,所以还要从vertex shader中传递颜色信息。和往常一样,pixel shader只计算每个像素的颜色。
Vertex Shader
vertex shader对顶点施加billboard,所以使用以下代码,这个代码来自于教程3-11中的球形billboarding:
//Technique: Explosion float3 BillboardVertex(float3 billboardCenter, float2 cornerID, float size) { float3 eyeVector = billboardCenter - xCamPos; float3 sideVector = cross(eyeVector,xCamUp); sideVector = normalize(sideVector); float3 upVector = cross(sideVector,eyeVector); upVector = normalize(upVector); float3 finalPosition = billboardCenter; finalPosition += (cornerID.x-0.5f)*sideVector*size; finalPosition += (0.5f-cornerID.y)*upVector*size; return finalPosition; }
简而言之,你将billboard的中心位置,纹理坐标(用来区分当前顶点)和billboard大小传递到这个方法,这个方法返回顶点的3D位置。更多的信息可参见教程3-11。
下面是vertex shader,它将使用刚才定义的方法:
ExpVertexToPixel ExplosionVS(float3 inPos: POSITION0, float4 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1) { ExpVertexToPixel Output = (ExpVertexToPixel)0; float3 startingPosition = mul(inPos, xWorld); float2 texCoords = inTexCoord.xy; float birthTime = inTexCoord.z; float maxAge = inTexCoord.w; float3 moveDirection = inExtra.xyz; float random = inExtra.w; }
vertex shader接受存储在每个顶点中的一个Vector3和两个Vector4。首先,将数据中的内容存储在一些变量中。Billboard中心位置存储在Vector3中。你已经定义了第一个Vector4中的x和y分量包含纹理坐标,第三第四个分量包含粒子创建的时间和存活时间。第二个Vector4包含粒子移动的方向和一个额外的随机浮点数。
现在,你可以将当前时间减去birthTime获取粒子已经存在的时间,存储在xTime变量中。但是,当使用time span时,你想用0和1之间的相对值表示这个时间,0表示开始时刻,1表示结束时刻。所以要将age除以maxAge, maxAge表示此时粒子应该“死亡”。
float age = xTime - birthTime; float relAge = age/maxAge;
当粒子是新的时,xTime与birthTime相同,relAge 为0。当接近于结束,age几乎等于maxAge,relAge接近于1。
首先根据粒子的age 调整它的大小。你想让每个粒子开始时最大然后慢慢变小。但是,你不想让它完全消失,所以需要使用以下代码:
float size = 1-relAge*relAge/2.0f;
看起来有点难以理解,如果用图表示可以帮助你理解这个代码,如图3-22左图所示。对横轴上的每个粒子的Age,你可以在在纵轴上找到对应的大小。例如,relAge = 0时大小为1,relAge = 1时大小为0.5f。
图3-23 Size-relAge函数; displacement-relAge函数
但是,当relAge为1时会继续减小。这意味着size会变为负值(如下面的代码所见,这会导致图像会在反方向扩展)。所以,你需要将这个值 处于0和1之间。
因为大小为1的粒子太小,只需简单地将它们乘以一个数字进行缩放,这里是5。要让每个粒子各不相同,你还要将大小乘以一个随机数:
float sizer = saturate(1-relAge*relAge/2.0f); float size = 5.0f*random*sizer;
现在粒子的大小随着时间的流逝会变小,下一步是计算粒子中心的3D位置。你已经知道初始3D位置(爆炸的中心位置)和粒子的移动方向。你需要知道粒子已经沿着这个方向移动了多远,当然,这个距离对应粒子的生存时间。一个简单的方法是让粒子以一个不变的速度移动,但是实际情况中这个速度会减小。
看一下图3-23中的右图,水平轴表示粒子的生存时间,竖直轴表示粒子离开中心位置的距离。你看到距离一开始是线性增加的,但过了一会儿,这个距离增加速度减小,最后不变。这意味着开始时速度保持不变最后为0。
幸运的是,这个曲线只是简单的正弦函数的四分之一。对于一个任意给定的位于0和1之间的relAge,你可以使用这个函数找到对应的曲线上的距离:
float totalDisplacement = sin(relAge*6.28f/4.0f);
一个完整的正弦曲线周期是从0到2*pi=6.28。因为你只想要四分之一周期,所以需要除以4。现在对于0和1之间的relAge,totalDisplacement拥有了一个对应图3-23右图所示的曲线的值。
你还要将这个值乘以一个因子使粒子离开中心一点儿,本例中这个因子为3比较合适。更大的值导致更大的爆炸。这个值还要乘以一个随机值,使每个粒子都有自己的速度:
float totalDisplacement = sin(relAge*6.28f/4.0f)*3.0f*random;
一旦知道了粒子沿着运动方向运动的距离,就可以很简单地获取粒子的当前位置:
float3 billboardCenter = startingPosition + totalDisplacement*moveDirection; billboardCenter += age*float3(0,-1,0)/1000.0f;
最后一行代码给粒子施加一个重力。因为xTime变量包含当前的以毫秒为单位的时间,这行代码会每秒让粒子下降一个单位。
技巧:如果你想对一个移动的物体施加爆炸效果,例如一架飞机,你只需简单地添加一条代码让所有粒子移向物体移动的方向。你可以在顶点的一个额外的TEXCOORD2向量中传递这个方向。
现在有了billboard 中心的3D位置和大小,你就做好了将这些值传递到billboarding方法中的准备:
float3 finalPosition = BillboardVertex(billboardCenter, texCoords, size); float4 finalPosition4 = float4(finalPosition, 1); float4x4 preViewProjection = mul (xView, xProjection); Output.Position = mul(finalPosition4, preViewProjection);
这个代码来自于教程3-11。首先计算billboarded位置。和往常一样,这个3D位置需要乘以一个 4 × 4矩阵转换为2D屏幕位置,但在这之前,float3需要被转换到float4。你将最终的2D位置存储在Output结构的Position变量中。
以上代码已经可以给出一个漂亮的结果,但是当粒子接近结束时再施加淡出效果会更棒。当粒子刚刚生成时,你想让它完全可见,当达到relAge = 1时,你想让它完全透明。
要实现这个效果,你可以使用线性减少,但是如果使用图3-23左图中的曲线效果会更好。这次,你想从1到0,所以无需除以2:
float alpha = 1-relAge*relAge;
现在就可以定义粒子的调制颜色了:
Output.Color = float4(0.5f,0.5f,0.5f,alpha);
在pixel shader中,你将每个像素的颜色乘以这个颜色。这会将像素的alpha值调整为这里计算的值,这样粒子就会慢慢变得透明。颜色的RGB分量也通过因子2柔化,不然你会很容易看到独立的粒子。
别忘了将纹理坐标传递到pixel shader,这样才能知道billboard的顶点对应纹理的哪个顶点:
Output.TexCoord = texCoords; return Output;
Pixel Shader
幸运的是,pixel shader相对于vertex shader来说很简单:
ExpPixelToFrame ExplosionPS(ExpVertexToPixel PSIn) : COLOR0 { ExpPixelToFrame Output = (ExpPixelToFrame)0; Output.Color = tex2D(textureSampler, PSIn.TexCoord)*PSIn.Color; return Output; }
对每个像素,你从纹理中获取对应的颜色,将这个颜色乘以在vertex shader计算的调制颜色。这个调制颜色会减少亮度,对应粒子的生存时间设置alpha值。
下面是technique定义:
technique Explosion { pass Pass0 { VertexShader = compile vs_1_1 ExplosionVS(); PixelShader = compile ps_1_1 ExplosionPS(); } }
在XNA中设置Technique参数
别忘了在XNA代码中设置所有XNA-to-HLSL变量:
expEffect.CurrentTechnique = expEffect.Techniques["Explosion"]; expEffect.Parameters["xWorld"].SetValue(Matrix.Identity); expEffect.Parameters["xProjection"].SetValue(quatMousCam.ProjectionMatrix); expEffect.Parameters["xView"].SetValue(quatMousCam.ViewMatrix); expEffect.Parameters["xCamPos"].SetValue(quatMousCam.Position); expEffect.Parameters["xExplosionTexture"].SetValue(myTexture); expEffect.Parameters["xCamUp"].SetValue(quatMousCam.UpVector); expEffect.Parameters["xTime"]. SetValue((float)gameTime.TotalGameTime.TotalMilliseconds);
Additive Blending(附加混合)
因为这个technique依赖于混合,你需要在绘制前设置绘制状态,alpha混合的更多信息可参加教程2-12和3-3。
在本例中,你想使用additive blending让所有的颜色都叠加在一起。这可以通过设置渲染状态实现:
device.RenderState.AlphaBlendEnable = true; device.RenderState.SourceBlend = Blend.SourceAlpha; device.RenderState.DestinationBlend = Blend.One;
第一行代码开启alpha混合。对每个像素,它的颜色决定于以下规则:
finalColor=sourceBlend*sourceColor+destBlend*destColor
根据前面设置的渲染状态,这个规则会变成以下公式:
finalColor=sourceAlpha*sourceColor+1*destColor
显卡会绘制每个像素多次,因为你将绘制大量的爆炸图像(需要关闭写入depth buffer)。所以当显卡已经绘制了80个粒子中的79个,并开始绘制最后一个时,会对这个粒子的每个像素进行以下操作:
1.找到存储在frame buffer中的像素的当前颜色,将三个颜色通道乘以1(对应前面规则中的1*destColor)。
2.获取在pixel shader中计算的这个粒子的新颜色,将这个颜色的三个通道乘以alpha通道,alpha通道取决于粒子的当前生存时间(对应上述规则中的sourceAlpha*sourceColor)。
3. 将两个颜色相加,将结果保存到frame buffer中。
在粒子的开始时刻(relAge=0),sourceAlpha等于1,所以additive blending会产生一个大爆炸。在粒子的结束时刻(relAge=1),newAlpha为0,粒子不产生效果。
注意:这个混合规则的第二个例子可参加教程2-12。
关闭写入到Depth Buffer
你还要考虑到最后一个方面。当离开相机最经的粒子首先被绘制时,所有在它之后的粒子将不会被绘制(这会导致不发生混合)!所以绘制粒子时,你需要关闭z-buffer测试,并在将粒子作为场景中的最后一个元素被绘制。但这并不是一个好方法,因为当爆炸离开相机很远时且相机和爆炸之间有一个建筑物时,爆炸仍会被绘制,就好像之间没有这个建筑物似的!
更好的方法是先绘制场景。然后关闭z-buffer写入将粒子作为最后一个元素进行绘制。通过这个方法,你可以解决这个问题:
- 每个粒子的每个像素都会与z-buffer中的当前内容(包含深度信息)作比较。如果在相机和爆炸之间已经有一个物体被绘制了,那么爆炸的粒子就通不过z-buffer测试,不会被绘制。
- 因为粒子不会改变z-buffer,就算第二个粒子在第一个粒子之后也会被绘制(当然,是在相机和爆炸间没有另一个物体的情况下)。
下面是代码:
device.RenderState.DepthBufferWriteEnable = false;
然后,绘制爆炸使用的三角形:
expEffect.Begin(); foreach (EffectPass pass in expEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexExplosion.VertexElements); device.DrawUserPrimitives<VertexExplosion> (PrimitiveType.TriangleList, explosionVertices, 0, explosionVertices.Length / 3); pass.End(); } expEffect.End();
在绘制完爆炸后,你需要再次开启z-buffer写入:
device.RenderState.DepthBufferWriteEnable = true;
当你想开始一个新的爆炸时,调用CreateExplosionVertices方法!本例中,当用户按下空格键时会调用这个方法:
if ((keyState.IsKeyDown(Keys.Space))) CreateExplosionVertices((float) gameTime.TotalGameTime.TotalMilliseconds);
代码
下面是生成爆炸所需顶点的方法:
private void CreateExplosionVertices(float time) { int particles = 80; explosionVertices = new VertexExplosion[particles * 6]; int i = 0; for (int partnr = 0; partnr < particles; partnr++) { Vector3 startingPos = new Vector3(5,0,0); float r1 = (float)rand.NextDouble() - 0.5f; float r2 = (float)rand.NextDouble() - 0.5f; float r3 = (float)rand.NextDouble() - 0.5f; Vector3 moveDirection = new Vector3(r1, r2, r3); moveDirection.Normalize(); float r4 = (float)rand.NextDouble(); r4 = r4 / 4.0f * 3.0f + 0.25f; explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 0, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 1, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); } }
下面是完整的Draw方法:
protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0); cCross.Draw(quatCam.ViewMatrix, quatCam.ProjectionMatrix); if (explosionVertices != null) { //draw billboards expEffect.CurrentTechnique = expEffect.Techniques["Explosion"]; expEffect.Parameters["xWorld"].SetValue(Matrix.Identity); expEffect.Parameters["xProjection"].SetValue(quatCam.ProjectionMatrix); expEffect.Parameters["xView"].SetValue(quatCam.ViewMatrix); expEffect.Parameters["xCamPos"].SetValue(quatCam.Position); expEffect.Parameters["xExplosionTexture"].SetValue(myTexture); expEffect.Parameters["xCamUp"].SetValue(quatCam.UpVector); expEffect.Parameters["xTime"]. SetValue((float)gameTime.TotalGameTime.TotalMilliseconds); device.RenderState.AlphaBlendEnable = true; device.RenderState.SourceBlend = Blend.SourceAlpha; device.RenderState.DestinationBlend = Blend.One; device.RenderState.DepthBufferWriteEnable = false; expEffect.Begin(); foreach (EffectPass pass in expEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexExplosion.VertexElements); device.DrawUserPrimitives<VertexExplosion> (PrimitiveType.TriangleList, explosionVertices, 0, explosionVertices.Length / 3); pass.End(); } expEffect.End(); device.RenderState.DepthBufferWriteEnable = true; } base.Draw(gameTime); }
下面是vertex shader代码:
ExpVertexToPixel ExplosionVS(float3 inPos: POSITION0, float4 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1) { ExpVertexToPixel Output = (ExpVertexToPixel)0; float3 startingPosition = mul(inPos, xWorld); float2 texCoords = inTexCoord.xy; float birthTime = inTexCoord.z; float maxAge = inTexCoord.w; float3 moveDirection = inExtra.xyz; float random = inExtra.w; float age = xTime - birthTime; float relAge = age/maxAge; float sizer = saturate(1-relAge*relAge/2.0f); float size = 5.0f*random*sizer; float totalDisplacement = sin(relAge*6.28f/4.0f)*3.0f*random; float3 billboardCenter = startingPosition + totalDisplacement*moveDirection; billboardCenter += age*float3(0,-1,0)/1000.0f; float3 finalPosition = BillboardVertex(billboardCenter, texCoords, size); float4 finalPosition4 = float4(finalPosition, 1); float4x4 preViewProjection = mul (xView, xProjection); Output.Position = mul(finalPosition4, preViewProjection); float alpha = 1-relAge*relAge; Output.Color = float4(0.5f,0.5f,0.5f,alpha); Output.TexCoord = texCoords; return Output; }
vertex shader使用前面定义的BillboardVertex方法,在教程的最后你还可以看到简单的pixel shader和technique定义。
译者注:在XNA官方网站上http://creators.xna.com/en-US/sample/particle3d也有一个粒子系统的例子,不过它的实现方法是使用点精灵(point sprite),这个方法没有使用billboard那样具有弹性,但是它的优点是只使用一个顶点就可以绘制一个粒子,而在billboard需要六个,这可以减少发送到显卡的数据量。