2.5 粒子系统火焰的开发
很多的游戏场景中会采用火焰或烟雾等作为点缀,以增强场景的真实性与吸引力。而目前最流行的实现火焰、烟雾等效果的技术就是粒子系统技术,本节将向读者介绍如何利用粒子系统开发出非常真实酷炫的火焰与烟雾特效。
2.5.1 火焰的基本原理
用粒子系统实现火焰效果的基本思想非常简单,将火焰看作是由一系列运动的粒子叠加而成。系统定时在固定的区域内生成新粒子,粒子生成后不断按照一定的规律运动并改变自身的颜色。当粒子运动满足一定的条件后,粒子消亡。对单个粒子而言,其生命周期过程如图2-18所示。
▲图2-18 粒子对象的生命过程
读者可能会觉得,是不是过于简单了,这样就可以产生游戏场景中真实的火焰效果吗?当然,如果系统中同时存在的粒子数量很少,则模拟的火焰效果并不像。但如果有大量的粒子同时存在,而开发人员又给予了粒子合适的初始位置、运动速度、起始颜色、终止颜色、尺寸、最大生命期等特性,就可以模拟出非常真实的火焰效果。
说明
实际粒子系统的开发中,开发人员需要根据目标特效的需求给出合适的各项粒子特性,就可以真实地模拟出火焰、烟雾、爆炸等不同的效果。
了解了粒子系统的基本思想后,下面介绍一下本节案例中采用的具体策略,具体内容如下。
● 每个粒子本质上是一个较小的纹理矩形,采用的纹理图中不完全透明区域的形状确定了粒子的基本形状。随不完全透明部分所占区域形状的不同,实现的粒子可以为任何形状,如星形、六边形等。本节案例中实际采用的是圆形,如图2-19所示。
▲图2-19 粒子纹理矩形的纹理图(1)
▲图2-20 粒子纹理矩形的纹理图(2)
● 粒子一般不是在固定的位置生成,而是在指定的区域内随机选择位置生成。对于本节火焰效果的案例而言,随机生成粒子的区域是火焰下方的一个矩形区域,如图2-20所示。
● 由于需要模拟的火焰整体形状下方宽,上方窄,因此,生成粒子的速度方向应该是偏向中心轴线的,也就是在左侧生成的粒子速度方向偏右,在右侧生成的粒子速度方向偏左。
● 粒子运动过程中不但位置需要发生变化,颜色也需要根据一定的规则变化。本节案例采用的粒子颜色变化策略是,着色器接收渲染管线传入的起始颜色、终止颜色、总衰减因子。然后根据当前片元距离粒子纹理矩形中心点的距离、总衰减因子、片元纹理采样颜色的透明度通道值、起始/终止颜色计算出当前片元的颜色,如图2-21所示。
▲图2-21 粒子中片元颜色值的计算策略
说明
从图2-21中的计算策略可以看出,纹理图中每个片元的颜色仅仅是透明度(alpha)色彩通道起了作用。因此,纹理图中完全透明的位置对应到粒子中的相应位置而言也是完全透明的,这样纹理图就起到了充当粒子形状模板的作用。实际开发中根据目标特效的需要,可以选择不完全透明区域是任何所需形状的纹理图,而且不完全透明区域的透明度一般也是渐变的,这样可以产生更加平滑的效果。
● 总衰减因子由Java程序计算并传入渲染管线,本案例中采用的总衰减因子计算策略很简单,粒子存在的生命期越长,总衰减因子值越小,计算公式为“(最大允许生命期-当前粒子生命期)/ 最大允许生命期”。
● 从片元颜色变化规律来说,总衰减因子越小,片元颜色越接近终止颜色,反之,则越接近起始颜色。同时随片元位置离粒子中心点距离的增加,片元颜色也越接近终止颜色,反之则越接近起始颜色。
2.5.2 火焰的开发步骤
上一小节介绍了用粒子系统实现火焰效果的基本原理,本小节将基于上一小节介绍的原理给出一个实现的案例Sample2_12,其运行效果如图2-22所示。
▲图2-22 案例Sample2_12的运行效果图
从图2-22中可以看出,场景中有4个火盆,每个火盆中都有一个粒子系统实现的火焰。但由于4个火焰粒子系统所采用的参数值不同,实际呈现出的有火焰效果也有烟雾效果。由于插图采用灰度印刷,可能看起来效果不是很好,建议读者采用真机设备运行本节案例观察体会。
了解了本节案例的运行效果后,就可以进行案例的开发了。由于本案例中一些代码和前面章节案例中的非常相似,因此,这里仅给出本案例中最有代表性的部分,具体内容如下所列。
(1)首先介绍的是火焰粒子系统的总控制类ParticleSystem,其具体代码如下。
代码位置:见随书光盘中源代码/第2章/Sample2_12/src/com/bn/Sample2_12目录下的ParticleSystem.java.
1 package com.bn.Sample2_12; 2 ……//此处省略了包的引入代码,读者可自行查阅随书光盘中的源代码 3 public class ParticleSystem implements Comparable<ParticleSystem>{ 4 //用于存放所有粒子的列表 5 public ArrayList<ParticleSingle> alFsp=new ArrayList<ParticleSingle>(); 6 //用于存放需要删除粒子的列表 7 ArrayList<ParticleSingle> alFspForDel=new ArrayList<ParticleSingle>(); 8 //用于为绘制工作转存所有粒子的列表 9 public ArrayList<ParticleSingle> alFspForDraw=new ArrayList<ParticleSingle>(); 10 //用于直接为绘制工作服务的粒子列表 11 public ArrayList<ParticleSingle> alFspForDrawTemp=new ArrayList<ParticleSingle>(); 12 Object lock=new Object(); //资源访问锁 13 public float[] startColor; //粒子起始颜色 14 public float[] endColor; //粒子终止颜色 15 public int srcBlend; //源混合因子 16 public int dstBlend; //目标混合因子 17 public int blendFunc; //混合方式 18 public float maxLifeSpan; //粒子最大生命期 19 public float lifeSpanStep; //粒子生命期步进 20 public int sleepSpan; //线程休眠时间 21 public int groupCount; //每批喷发的粒子数量 22 public float sx; //基础发射点x坐标 23 public float sy; //基础发射点y坐标 24 float positionX; //绘制位置x坐标 25 float positionZ; //绘制位置y坐标 26 public float xRange; //发射点x方向的变化范围 27 public float yRange; //发射点y方向的变化范围 28 public float vx; //粒子发射的x方向速度 29 public float vy; //粒子发射的y方向速度 30 float yAngle=0; //此粒子系统的旋转角度 31 ParticleForDraw fpfd; //单个粒子的绘制者
32 boolean flag=true; //线程工作的标志位 33 public ParticleSystem(float positionx,float positionz,ParticleForDraw fpfd){ 34 this.positionX=positionx; //初始化此粒子系统的绘制位置x坐标 35 this.positionZ=positionz; //初始化此粒子系统的绘制位置z坐标 36 this.startColor=START_COLOR[CURR_INDEX]; //初始化粒子起始颜色 37 this.endColor=END_COLOR[CURR_INDEX]; //初始化粒子终止颜色 38 this.srcBlend=SRC_BLEND[CURR_INDEX]; //初始化源混合因子 39 this.dstBlend=DST_BLEND[CURR_INDEX]; //初始化目标混合因子 40 this.blendFunc=BLEND_FUNC[CURR_INDEX]; //初始化混合方式 41 this.maxLifeSpan=MAX_LIFE_SPAN[CURR_INDEX]; //初始化每个粒子的最大生命周期 42 this.lifeSpanStep=LIFE_SPAN_STEP[CURR_INDEX]; //初始化每个粒子的生命步进 43 this.groupCount=GROUP_COUNT[CURR_INDEX]; //初始化每批喷发的粒子数 44 this.sleepSpan=THREAD_SLEEP[CURR_INDEX]; //初始化线程的休眠时间 45 this.sx=0; //初始化此粒子系统的中心点x坐标 46 this.sy=0; //初始化此粒子系统的中心点y坐标 47 this.xRange=X_RANGE[CURR_INDEX]; //初始粒子距离中心点x方向的最大距离 48 this.yRange=Y_RANGE[CURR_INDEX]; //初始粒子距离中心点y方向的最大距离 49 this.vx=0; //初始化粒子的x方向运动速度 50 this.vy=VY[CURR_INDEX]; //初始化粒子的y方向运动速度 51 this.fpfd=fpfd; //初始化单个粒子的绘制者 52 new Thread(){ //创建粒子的更新线程 53 public void run(){ 54 while(flag){ 55 update(); //调用update方法更新粒子状态 56 try{ 57 Thread.sleep(sleepSpan); //休眠一定的时间 58 } 59 catch(InterruptedException e){ 60 e.printStackTrace(); 61 }}} 62 }.start(); 63 } 64 public void drawSelf(int texId){ //绘制方法 65 ……//此处省略了部分源代码,将在后面的步骤中给出 66 } 67 public void update(){ //更新粒子状态的方法 68 ……//此处省略了部分源代码,将在后面的步骤中给出 69 } 70 ……//此处省略了重写的比较两个火焰离摄像机距离的方法,读者可自行查阅随书光盘中的源代码 71 }
说明
从上述代码中可以看出,每个ParticleSystem类的对象代表一个粒子系统。其中有一系列的成员变量用于存储对应粒子系统的各项属性信息。同时,其中还开启了一个定时更新粒子系统状态的线程,其定时调用更新粒子状态的update方法,此方法将在下面详细介绍。
(2)接下来给出前面介绍ParticleSystem类时省略的drawSelf方法,该方法主要负责绘制整个粒子系统,其具体代码如下。
代码位置:见随书光盘中源代码/第2章/Sample2_12/src/com/bn/Sample2_12目录下的ParticleSystem.java.
1 public void drawSelf(int texId){ 2 GLES20.glDisable(GLES20.GL_DEPTH_TEST); //关闭深度检测 3 GLES20.glEnable(GLES20.GL_BLEND); //开启混合 4 GLES20.glBlendEquation(blendFunc); //设置混合方式 5 GLES20.glBlendFunc(srcBlend,dstBlend); //设置混合因子 6 //清空用于直接为绘制工作服务的粒子列表,为向此列表中添加当前存在的粒子做准备 7 alFspForDrawTemp.clear(); 8 synchronized(lock){ 9 //加锁的目的是为了防止alFspForDraw列表被两个线程同时访问而出问题 10 for(int i=0;i<alFspForDraw.size();i++){ 11 alFspForDrawTemp.add(alFspForDraw.get(i)); //复制粒子 12 }} 13 MatrixState.translate(positionX, 1, positionZ); //执行平移变换
14 MatrixState.rotate(yAngle, 0, 1, 0); //执行旋转变换 15 for(ParticleSingle fsp:alFspForDrawTemp){ //循环绘制每个粒子 16 fsp.drawSelf(texId,startColor,endColor,maxLifeSpan); 17 } 18 GLES20.glEnable(GLES20.GL_DEPTH_TEST); //开启深度检测 19 GLES20.glDisable(GLES20.GL_BLEND); //关闭混合 20 }
● 第2-5行完成了绘制粒子系统前的一些必要设置,首先关闭深度测试,然后开启混合,最后根据初始化得到的混合方式与混合因子进行混合相关参数的设置。
● 第6-12行将转存粒子列表中的粒子复制进直接服务于绘制工作的粒子列表,为下面的粒子绘制工作做准备。要特别注意的是,复制的任务是在获得资源锁的同步代码块中进行的。这是由于转存粒子列表不但被绘制线程访问,在粒子的更新线程中还会访问,为了防止两个不同的线程同时对一个列表执行读写带来的问题,这里应该采用同步互斥技术。
● 第13-17行首先进行了平移和旋转变换,然后遍历整个直接服务于绘制工作的粒子列表,绘制其中的每一个粒子。
提示
由于本案例中粒子系统产生的特效实际是2D的,所以,在绘制粒子系统之前需要执行相应的旋转变换,将粒子系统旋转到正对摄像机的角度。这实际上用到了本书第一卷所介绍的标志板技术,有需要的读者可以参考本书第一卷中的相关内容。
(3)接着给出前面介绍ParticleSystem类时省略的update方法,该方法主要负责更新整个粒子系统,其具体代码如下。
代码位置:见随书光盘中源代码/第2章/Sample2_12/src/com/bn/Sample2_12目录下的ParticleSystem.java。
1 public void update(){ //更新粒子系统的方法
2 for(int i=0;i<groupCount;i++){ //循环发射一批新粒子
3 //在发射中心位置附近随机产生此次发射粒子的位置
4 float px=(float)(sx+xRange*(Math.random()*2-1.0f));
5 float py=(float)(sy+yRange*(Math.random()*2-1.0f));
6 float vx=(sx-px)/150; //粒子x方向速度
7 ParticleSingle fsp=new ParticleSingle(px,py,vx,vy,fpfd);//创建粒子对象
8 alFsp.add(fsp); //将生成的粒子加入用于存放所有粒子的列表中
9 }
10 //清空记录需要删除粒子的列表
11 alFspForDel.clear();
12 for(ParticleSingle fsp:alFsp){ //遍历更新当前的所有粒子
13 //调用每个粒子的go方法,实施粒子的变化
14 fsp.go(lifeSpanStep);
15 //如果粒子生存的时间已经达到了最大值,就将其添加到需要删除的粒子列表
16 if(fsp.lifeSpan>this.maxLifeSpan){
17 alFspForDel.add(fsp);
18 }}
19 for(ParticleSingle fsp:alFspForDel){ //将需要删除粒子列表中的粒子从所有粒子列表中删除
20 alFsp.remove(fsp);
21 }
22 synchronized(lock){ //获取访问锁
23 alFspForDraw.clear(); //清空转存粒子列表
24 for(int i=0;i<alFsp.size();i++){ //循环将所有粒子列表中的粒子添加到转存粒子列表中
25 alFspForDraw.add(alFsp.get(i));
26 }}}
● 第2-9行的功能为产生一批新的粒子,粒子的初始位置在指定的中心点位置附近随机产生。同时由于期望的火焰是向上逐渐收窄的,因此,根据粒子初始位置偏离中心位置x坐标的差值确定粒子 x 方向的速度。总的来说,x 方向速度指向中心点,速度大小与偏离中心点的距离线性相关,偏离越远,速度越大。
● 第10-21行的功能为将超过生命期上限的粒子从所有粒子列表中删除。但直接在遍历所有粒子列表的循环中执行删除会带来问题,故这里首先遍历所有粒子列表中的粒子,将符合删除条件的粒子加入到删除列表中,最后遍历删除列表,执行最后删除。这是Java编程中批量删除列表中满足条件的元素时常用的技巧,读者以后也可以采用。
● 第22-26行的功能为将更新后的所有粒子列表中的粒子复制进转存粒子列表。这与前面绘制方法中将转存粒子列表中的粒子复制进直接服务于绘制工作的粒子列表是呼应的,正好形成了粒子数据从计算线程到绘制线程的流水线。
提示
有的读者可能会有一个疑问?为什么需要3个列表(所有粒子、转存、绘制),而不是直接遍历绘制所有粒子列表中的粒子即可。这是因为若是如此,同时就会有更新线程与绘制线程都要访问所有粒子列表,可能会产生由于无限制并发访问引发的画面撕裂问题。若通过直接加锁解决的话,两个线程实际就不是并行了,影响效率。因此,本案例中采用了3个列表,形成了一个流水线,既避免了多线程并发访问带来的问题,又保证了效率。
具体的流水线是:更新线程进行粒子的计算,计算完毕后加锁,将粒子复制进转存列表;绘制线程加锁,将转存列表复制进绘制列表。这样加锁区域涉及的任务很少,执行时间很短(也就是临界区小),使得两个线程几乎不受影响,不影响效率。这是一种常用的多线程开发技巧,读者也可以在自己的项目中采用。
(4)下面介绍的是代表单个粒子的ParticleSingle类,其负责存储单个特定粒子的信息,具体代码如下。
代码位置:见随书光盘中源代码/第2章/Sample2_12/src/com/bn/Sample2_12目录下的ParticleSingle.java.
1 package com.bn.Sample2_12; //包声明 2 public class ParticleSingle{ 3 public float x; //粒子的x坐标 4 public float y; //粒子的y坐标 5 public float vx; //粒子的x方向速度 6 public float vy; //粒子的y方向速度 7 public float lifeSpan; //粒子的生命期 8 ParticleForDraw fpfd; //粒子对象的绘制者 9 public ParticleSingle(float x,float y,float vx,float vy,ParticleForDraw fpfd){ 10 this.x=x; //初始化粒子的x坐标 11 this.y=y; //初始化粒子的y坐标 12 this.vx=vx; //初始化粒子的x方向速度 13 this.vy=vy; //初始化粒子的y方向速度 14 this.fpfd=fpfd; //初始化粒子对象的绘制者 15 } 16 public void go(float lifeSpanStep){ //移动粒子,并增长粒子生命期的方法 17 x=x+vx; //计算粒子新的x坐标 18 y=y+vy; //计算粒子新的y坐标 19 lifeSpan+=lifeSpanStep; //增加粒子的生命期 20 } 21 public void drawSelf(int texId,float[] startColor,float[] endColor,float maxLife Span){ 22 MatrixState.pushMatrix(); //保护现场 23 MatrixState.translate(x, y, 0); //执行平移变换 24 float sj=(maxLifeSpan-lifeSpan)/maxLifeSpan; //计算总衰减因子 25 fpfd.drawSelf(texId,sj,startColor,endColor); //绘制单个粒子 26 MatrixState.popMatrix(); //恢复现场 27 }}
● 第3-14行为单个粒子对象基本信息对应成员变量的声明与初始化,主要包括位置、速度、绘制者等。
● 第16-20行为定时被调用用以运动粒子及增大粒子生命期的go方法。
● 第21-27行为绘制单个粒子的drawSelf方法,其中最主要的工作是,首先根据粒子当前的生命期与最大允许生命期计算出总衰减因子,然后调用粒子绘制者的drawSelf方法完成粒子的绘制工作。
说明
从第24行计算总衰减因子的代码中可以看出,随着粒子生命期的增加,总衰减因子逐渐减小,直至为0。结合上一小节介绍的片元颜色变化规律可以看出,随着粒子生命期的增加,片元的颜色逐渐接近终止颜色。
(5)从前面的图2-22中可以看出,本节案例中火焰有4种不同的效果。同时,前面介绍基本原理时也提到过,实际粒子系统的开发中,开发人员需要根据目标特效的需求给出合适的各项粒子特性,就可以真实地模拟出火焰、烟雾、爆炸等不同的效果。因此,为了使用方便,本节案例中将4种不同效果需要的特性数据封装进了一个常量类ParticleDataConstant,其具体代码如下。
代码位置:见随书光盘中源代码/第2章/Sample2_12/src/com/bn/Sample2_12目录下的ParticleDataConstant.java
1 package com.bn.Sample2_12; //包声明 2 import android.opengl.GLES20; //相关类的引用 3 public class ParticleDataConstant{ 4 ……//此处省略部分变量的声明,读者可自行查阅光盘的源代码 5 public static final float[][] START_COLOR={ //粒子起始颜色 6 {0.7569f,0.2471f,0.1176f,1.0f}, //0-普通火焰 7 {0.7569f,0.2471f,0.1176f,1.0f}, //1-白亮火焰 8 {0.6f,0.6f,0.6f,1.0f}, //2-普通烟 9 {0.6f,0.6f,0.6f,1.0f}, //3-纯黑烟 10 }; 11 public static final float[][] END_COLOR={ //粒子终止颜色 12 {0.0f,0.0f,0.0f,0.0f}, //0-普通火焰 13 {0.0f,0.0f,0.0f,0.0f}, //1-白亮火焰 14 {0.0f,0.0f,0.0f,0.0f}, //2-普通烟 15 {0.0f,0.0f,0.0f,0.0f}, //3-纯黑烟 16 }; 17 public static final int[] SRC_BLEND={ //源混合因子 18 GLES20.GL_SRC_ALPHA, //0-普通火焰 19 GLES20.GL_ONE, //1-白亮火焰 20 GLES20.GL_SRC_ALPHA, //2-普通烟 21 GLES20.GL_ONE, //3-纯黑烟 22 }; 23 public static final int[] DST_BLEND={ //目标混合因子 24 GLES20.GL_ONE, //0-普通火焰 25 GLES20.GL_ONE, //1-白亮火焰 26 GLES20.GL_ONE_MINUS_SRC_ALPHA, //2-普通烟 27 GLES20.GL_ONE, //3-纯黑烟 28 }; 29 public static final int[] BLEND_FUNC={ //混合方式 30 GLES20.GL_FUNC_ADD, //0-普通火焰 31 GLES20.GL_FUNC_ADD, //1-白亮火焰 32 GLES20.GL_FUNC_ADD, //2-普通烟 33 GLES20.GL_FUNC_REVERSE_SUBTRACT, //3-纯黑烟 34 }; 35 public static final float[] RADIS={ //单个粒子半径 36 0.5f, //0-普通火焰 37 0.5f, //1-白亮火焰 38 0.8f, //2-普通烟 39 0.8f, //3-纯黑烟 40 }; 41 public static final float[] MAX_LIFE_SPAN={ //粒子最大生命期 42 6.0f, //0-普通火焰
43 6.0f, //1-白亮火焰 44 7.0f, //2-普通烟 45 7.0f, //3-纯黑烟 46 }; 47 public static final float[] LIFE_SPAN_STEP={ //粒子生命期步进 48 0.07f, //0-普通火焰 49 0.07f, //1-白亮火焰 50 0.07f, //2-普通烟 51 0.07f, //3-纯黑烟 52 }; 53 public static final float[] X_RANGE={ //粒子发射的x左右范围 54 0.5f, //0-普通火焰 55 0.5f, //1-白亮火焰 56 0.5f, //2-普通烟 57 0.5f, //3-纯黑烟 58 }; 59 public static final float[] Y_RANGE={ //粒子发射的y上下范围 60 0.3f, //0-普通火焰 61 0.3f, //1-白亮火焰 62 0.15f, //2-普通烟 63 0.15f, //3-纯黑烟 64 }; 65 public static final int[] GROUP_COUNT={ //每次发射的粒子数量 66 4, //0-普通火焰 67 4, //1-白亮火焰 68 1, //2-普通烟 69 1, //3-纯黑烟 70 }; 71 public static final float[] VY={ //粒子y方向升腾的速度 72 0.05f, //0-普通火焰 73 0.05f, //1-白亮火焰 74 0.04f, //2-普通烟 75 0.04f, //3-纯黑烟 76 }; 77 public static final int[] THREAD_SLEEP={ //粒子更新物理线程休眠时间(ms) 78 60, //0-普通火焰 79 60, //1-白亮火焰 80 30, //2-普通烟 81 30, //3-纯黑烟 82 };}
● 本案例中一共有4种粒子特效,每一种粒子特效系统中采用的各项参数是不同的,具体包括起始颜色、终止颜色、混合因子,混合方式、最大允许生命期等。
● 混合方式以及混合因子的基础知识,在本书第一卷中已经进行了详细介绍,需要了解的读者可以查阅本书第一卷中的相关内容,这里不再赘述。
(6)最后给出的是实现控制片源颜色变化的片源着色器,其代码如下。
代码位置:见随书光盘中源代码/第2章/Sample2_12/assets目录下的flag.sh。
1 precision mediump float; //给出默认浮点精度 2 uniform vec4 startColor; //起始颜色 3 uniform vec4 endColor; //终止颜色 4 uniform float sjFactor; //总衰减因子 5 uniform float bj; //纹理矩形半径 6 uniform sampler2D sTexture; //纹理内容数据 7 varying vec2 vTextureCoord; //接收从顶点着色器传过来的纹理坐标 8 varying vec3 vPosition; //接收从顶点着色器传过来的片元位置 9 void main(){ 10 vec4 colorTL = texture2D(sTexture, vTextureCoord); //进行纹理采样 11 vec4 colorT; //颜色变量 12 float disT=distance(vPosition,vec3(0.0,0.0,0.0)); //计算当前片元与中心点的距离 13 float tampFactor=(1.0-disT/bj)*sjFactor; //计算片元颜色插值因子 14 vec4 factor4=vec4(tampFactor,tampFactor,tampFactor,tampFactor); 15 colorT=clamp(factor4,endColor,startColor); //进行颜色插值 16 colorT=colorT*colorTL.a; //结合采样出的透明度计算最终颜色 17 gl_FragColor=colorT; //将计算出来的片元颜色传给渲染管线 18 }
提示
上述片元着色器实现了上一小节图2-21中给出的粒子中片元颜色值的计算策略,主体思想就是在计算出片元颜色插值因子后,通过在起始颜色与终止颜色间进行线性插值,并结合纹理采样颜色的透明度得出最终的片元颜色。