在本篇,我们将开启一个新的项目,探索粒子的世界。粒子是一种基本的图形元素,它们通常被表示为一组点。通过巧妙地组合一些基础的物理效果,我们能够创造出许多令人惊叹的视觉效果。想象一下,我们可以模拟一个水滴从喷泉中喷出,然后优雅地落回地面的场景。同样,我们也能模拟出逼真的下雨效果,或者制作出爆炸和烟花的动画。粒子系统的数学原理相对简单,这使得它们很容易被集成到任何三维场景中。
在本章,我们将逐步构建一个粒子系统。首先,我们会介绍创建粒子系统所需的基本要素。接下来,我们将添加喷泉效果,让粒子喷射到空中。此外,我们还会探讨如何通过技术手段,如混合和点精灵,来提升粒子的视觉效果,使它们看起来更加好看。
创建着色器
作为开始,我们将构建一个基础的粒子系统,模拟一个喷泉。这个喷泉可以想象成在灯光下喷发的泉水,或者像烟花表演中的喷泉一样。为了实现这个效果,我们需要处理一些技术细节。
首先,我们需要找到一种方式来在内存中表示所有的粒子。虽然可以使用Java对象数组,但在运行时创建和删除大量对象可能会导致资源消耗过大,并且没有简单的方法将数据传递给OpenGL。因此,我们选择将所有粒子数据嵌入到一个固定大小的数组中。添加粒子时,我们只需增加粒子计数,将数据写入粒子数组,并把变化的内容复制到本地缓冲区。当空间不足时,可以通过在数组开头重新开始来回收空间。
接下来,我们需要一种方法来绘制每个粒子。我们将每个粒子表示为一个顶点,并绘制一组点,每个点都有其独特的位置和颜色。
最后,我们需要一种方法来更新这些粒子。将这些逻辑放入着色器程序中,可以让GPU分担一部分更新工作。对于每个粒子,我们需要存储一个方向向量和一个创建时间。利用创建时间,我们可以计算出粒子自创建以来经过的时间,然后使用这个时间、方向向量和位置来推算出粒子当前的位置。我们将使用一个浮点数来存储时间,并以0.0表示粒子系统开始运行的时间。
有了这些基本需求,我们可以为着色器程序制定一套初始规范。首先,我们需要定义一个uniform变量用于投影矩阵,以及一个用于当前时间的uniform变量,以便着色器计算出每个粒子自创建以来经过的时间。我们还需要定义四个与粒子属性相对应的属性:位置、颜色、方向向量和创建时间。
1.编写顶点着色器
让我们开始给着色器添加代码。继续并在“/res/raw/”文件夹内创建一个名为“particle_vertex_shader.glsl”的新顶点着色器。首先以如下定义作为开始:
uniform mat4 u_Matrix;
uniform float u_Time;attribute vec3 a_Position;
attribute vec3 a_Color;
attribute vec3 a_DirectionVector;
attribute float a_ParticleStartTime;varying vec3 v_Color;
varying float v_ElapsedTime;void main(){v_Color = a_Color;v_ElapsedTime = u_Time - a_ParticleStartTime;vec3 currentPosition = a_Position + (a_DirectionVector * v_ElapsedTime);gl_Position = u_Matrix * vec4(currentPosition,1.0);gl_PointSize = 10.0;
}
这些定义满足了我们的粒子着色器的需求。我们在片段着色器中也需要使用颜色和运行时间,因此我们也为这两个变量创建了两个varying。
在main函数中,我们首先把颜色发送给片段着色器,接着计算粒子从被创建之后运行了多少时间,并且把那个时间也发送给片段着色器。为了计算粒子的当前位置,我们把方向向量与运行时间相乘,并与a_Position相加。运行时间越长,粒子走得越远。
要完成这个着色器代码,我们把粒子用那个矩阵进行投影,而且,因为我们把粒子渲染为一个点,所以把点的大小设成了10个像素。
当我们做数学运算时,要确保不会意外地把w分量弄混乱,这是很重要的。因此,我们将用3分量向量表示位置和方向,只有需要把它与u_Matrix相乘时,才把它转换为完全的4分量向量。这确保上面的数学运算只影响x、y和z分量。
2.编写片段着色器
现在我们可以继续并添加片段着色器了。在与顶点着色器相同的地方创建一个被称为“particle_fragment_shader.glsl”的新文件,并加入如下代码:
precision mediump float;
varying vec3 v_Color;
varying float v_ElapsedTime;void main(){gl_FragColor = vec4(v_Color / v_ElapsedTime,1.0);
}
通过把颜色除以运行时间,这个着色器会使年轻的粒子明亮,而使年老的粒子暗淡。如果发生除以0的情况怎么办?根据规范,这会导致一个不明确的结果,但不会导致着色器程序终止。要一个更加可预测的结果,你可以总是给分母加上一个很小的数。
3.用一个类封装着色器
着色器代码完成了,我们现在可以用一个类封装着色器,它使用我们在本书第一部分所使用的模式。让我们首先给 ShaderProgram加入一些新的常量:
protected val U_TIME = "u_Time"protected val A_DIRECTION_VECTOR = "a_DirectionVector"protected val A_PARTICLE_START_TIME = "a_ParticleStartTime"
这些新的常量被定义后,我们可以继续加入一个名为“ParticleShaderProgram”的新类,它继承自ShaderProgram,并以如下代码作为这个类的开始:
private var uMatrixLocation = 0private var uTimeLocation = 0var aPositionLocation = 0var aColorLocation = 0var aDirectionVectorLocation = 0var aParticleStartTimeLocation = 0
接着继续完成这个类的定义:
constructor(context: Context):super(context, R.raw.particle_vertex_shader,R.raw.particle_fragment_shader1){uMatrixLocation = findUniformLocationByName(U_MATRIX)uTimeLocation = findUniformLocationByName(U_TIME)aPositionLocation = findAttribLocationByName(A_POSITION)aColorLocation = findAttribLocationByName(A_COLOR)aDirectionVectorLocation = findAttribLocationByName(A_DIRECTION_VECTOR)aParticleStartTimeLocation = findAttribLocationByName(A_PARTICLE_START_TIME)uTextureUnitLocation = findUniformLocationByName(U_TEXTURE_UNIT)}fun setUniforms(matrix:FloatArray,elapsedTime:Float){GLES20.glUniformMatrix4fv(uMatrixLocation,1,false,matrix,0)GLES20.glUniform1f(uTimeLocation,elapsedTime)}
封装模式与前面篇章的一致,这里就不再过多赘述。
添加粒子系统
我们现在可以开始创建粒子系统了。让我们创建一个名为“ParticleSystem”的新类,这个类以如下代码开始:
private val POSITION_COMPONENT_COUNT = 3private val COLOR_COMPONENT_COUNT = 3private val VECTOR_COMPONENT_COUNT = 3private val PARTICLE_START_TIME_COMPONENT_COUNT = 1private val TOTAL_COMPONENT_COUNT = POSITION_COMPONENT_COUNT + COLOR_COMPONENT_COUNT + VECTOR_COMPONENT_COUNT + PARTICLE_START_TIME_COMPONENT_COUNTprivate val STRIDE = TOTAL_COMPONENT_COUNT * BYTES_PER_FLOAT
目前为止,我们只有一些基本定义,它们是为分量计数和粒子之间的跨距添加的。让我们继续构建这个类:
private var particles:FloatArrayprivate var vertexArray:VertexArrayprivate var maxParticleCount = 0private var currentParticleCount = 0private var nextParticle = 0constructor(maxParticleCount:Int){particles = FloatArray(maxParticleCount*TOTAL_COMPONENT_COUNT)vertexArray = VertexArray(particles)this.maxParticleCount = maxParticleCount}
我们现在有了一个存储粒子的浮点数组和一个VertexArray,VertexArra们要发送给OpenGL的数据,因为数组的大小是固定的,我们还定义了保maxParticleCount。我们将使用currentParticleCount和nextParticle让我们开始构建一个名为addParticle()的新方法:
fun addParticle(position:Point,color:Int,direction:Vector,particleStartTime:Float){val particleOffset = nextParticle * TOTAL_COMPONENT_COUNTvar currentOffset = particleOffsetnextParticle++if(currentParticleCount < maxParticleCount){currentParticleCount++}if(nextParticle == maxParticleCount){nextParticle = 0}
要创建新的粒子,首先要传入位置、颜色、方向和粒子创建时间。颜色作为Android的颜色定义被传递进来,我们需要用Android的Color类把这个颜色分解为单独的分量。
在给数组加入新的粒子之前,我们需要计算它需要存在哪里。因为所有的粒子都一起存在我们的数组中,它类似于一种无定形的blob(二进制大对象)。为了计算正确的偏移值,我们使用nextParticle存储下一个粒子的编号,其中第一个粒子从0开始编号。然后,我们可以通过把nextParticle与每个粒子的分量计数相乘得到偏移值。我们把这个偏移值存储在 particleOffset和 currentOffset中;我们使用 particleOffset记住新粒子从哪里开始,而用currentOffset记住新粒子的每个属性的位置。
一个新粒子每次被添加进来时,我们就给nextParticle增加1,当到了数组的结尾处,我们就从0开始以便回收最旧的粒子。我们也需要记录有多少个粒子需要被绘制出来,为此,一个新粒子每次被加入的时候,我们都增加currentParticleCount,但要把它限制在最大粒子数内。
我们已经解释了这些操作,接下来让我们把这个新的粒子数据写到数组中:
particles[currentOffset++] = position.xparticles[currentOffset++] = position.yparticles[currentOffset++] = position.zparticles[currentOffset++] = Color.red(color)/255fparticles[currentOffset++] = Color.green(color)/255fparticles[currentOffset++] = Color.blue(color)/255fparticles[currentOffset++] = direction.xparticles[currentOffset++] = direction.yparticles[currentOffset++] = direction.zparticles[currentOffset++] = particleStartTime
首先存进的是位置,然后是颜色(使用Android的Color类分解出每个分量),紧接着是方向向量,最后是粒子创建时间。Android的Color类返回的分量范围是从0到255,而OpenGL期望的范围是从0到1,因此,通过用每个分量都除以255,我们把它从Android的范围转换为OpenGL期望的范围。
我们还需要把这个新的粒子复制到本地缓冲区,以便OpenGL可以存取这些新的数据,因此,让我们用下面的方法调用完成addParticle():
vertexArray.updateBuffer(particles,particleOffset,TOTAL_COMPONENT_COUNT)
我们只想复制这些新数据,这样就不会浪费时间去复制那些没有改变的数据,因此我们传递进起始偏移值和那个计数。由于updateBuffer()作为VertexArray内部的一个新方法,我们还需要加入它的定义。为此,我们在类的结尾处加入如下代码:
fun updateBuffer(vertexData:FloatArray,start:Int,count:Int){floatBuffer.position(start)floatBuffer.put(vertexData,start,count)floatBuffer.position(0)}
现在,我们可以返回 ParticleSystem,并加入一个绑定函数:
fun bindData(particleProgram:ParticleShaderProgram){var dataOffset = 0vertexArray.setVertexAttribPointer(dataOffset,particleProgram.aPositionLocation,POSITION_COMPONENT_COUNT,STRIDE)dataOffset += POSITION_COMPONENT_COUNTvertexArray.setVertexAttribPointer(dataOffset,particleProgram.aColorLocation,COLOR_COMPONENT_COUNT,STRIDE)dataOffset += COLOR_COMPONENT_COUNTvertexArray.setVertexAttribPointer(dataOffset,particleProgram.aDirectionVectorLocation,VECTOR_COMPONENT_COUNT,STRIDE)dataOffset += VECTOR_COMPONENT_COUNTvertexArray.setVertexAttribPointer(dataOffset,particleProgram.aParticleStartTimeLocation,PARTICLE_START_TIME_COMPONENT_COUNT,STRIDE)}
这又只是一段样板式的代码,它遵循与前面几篇一样的模式,在着色器程序中把顶点数据与正确的属性绑定,并小心遵守我们在addParticle()中所使用的顺序。如果我们把颜色和方向向量的顺序搞混了,或者犯了此类错误,当我们绘制那些粒子时,我们将看到相当抽象的结果。
让我们加入一个draw()函数来结束这个类:
fun draw(){GLES20.glDrawArrays(GLES20.GL_POINTS,0,currentParticleCount)}
我们现在就实现了一个粒子系统。这个系统可以让我们添加一定量的粒子,回收旧的粒子,并在内存中有效地定位那些彼此相邻的粒子。
添加一个粒子喷泉
有了这个粒子系统,我们现在需要某个程序,它可以为我们实际生成一些粒子,并把它们添加到粒子系统中。让我们创建一个粒子喷泉,继续创建一个名为“ParticleShooter”的新类,并加入如下代码:
class ParticleShooter(var position:Point,var direction:Vector,var color:Int) {}
我们已经给定了这个粒子发射器的位置、方向和颜色;当我们创建新的粒子时,我们只要把这些直接传递给粒子系统。让我们继续编写这个粒子发射器:
fun addParticles(particleSystem: ParticleSystem,currentTime:Float,count:Int){for(i in 0 until count) {particleSystem.addParticle(position, color, direction, currentTime)}}
在 addParticles()中,我们传进了粒子系统、要添加多少粒子以及粒子系统的当前时间。我们现在已经有了所有的组件,只需要在Renderer类中加入一些调用把这一切组合起来。
绘制粒子系统
只需要给ParticlesRenderer加入一些代码,就能最终看到这些粒子了。让我们以下面的定义开始:
class ParticlesRenderer:Renderer {var context:Contextvar projectionMatrix = FloatArray(16)var viewMatrix = FloatArray(16)var viewProjectionMatrix = FloatArray(16)lateinit var particleProgram:ParticleShaderProgramlateinit var particleSystem: ParticleSystemlateinit var redParticleShooter: ParticleShooterlateinit var greenParticleShooter: ParticleShooterlateinit var blueParticleShooter: ParticleShootervar globalStartTime:Long = 0Lconstructor(context: Context){this.context = context}
这里定义了Android的Context和那些矩阵的标准变量,以及粒子着色器、粒子系统和三个粒子发射器的变量。我们还定义了全局启动时间的变量和一个标准构造函数。
接下来,让我们定义onSurfaceCreated()的内容:
override fun onSurfaceCreated(p0: GL10?, p1: EGLConfig?) {GLES20.glClearColor(0.0f,0.0f,0.0f,0.0f)particleProgram = ParticleShaderProgram(context)particleSystem = ParticleSystem(10000)globalStartTime = System.nanoTime()val particleDirection = Vector(0f,0.5f,0f)redParticleShooter = ParticleShooter(Point(-1f,0f,0f),particleDirection, Color.rgb(255,50,5))greenParticleShooter = ParticleShooter(Point(0f,0f,0f),particleDirection, Color.rgb(25,255,25))blueParticleShooter = ParticleShooter(Point(1f,0f,0f),particleDirection, Color.rgb(5,50,255))}
我们把清屏的颜色设为黑色,初始化粒子着色器程序,并用一万个粒子的最大限量初始化一个新的粒子系统,然后,我们使用System.nanoTime()获得当前系统时间,并用它设置全局启动时间。我们想在粒子系统中使用浮点数时间,因此,当粒子系统被初始化时,当前时间将是0.0,在此时创建的粒子,其创建时间将是0.0。5秒以后,新粒子的创建时间是5.0。为此,我们可以用当前系统时间与globalStartTime的差值作为创建时间,因为System.nanoTime()返回以纳秒为单位的时间,我们只要把这个差值除以一万亿就可以转换为秒。
这个方法的下半部分建立了三个粒子喷泉。每个喷泉由一个粒子发射器表示,每个发射器都将按照particleDirection定义的方向或者沿y轴垂直向上发射它的粒子。我们把这三个喷泉按照从左到右的顺序排列,并且设置了它们的颜色:第一个是红色,第二个是绿色,第三个是蓝色。
让我们加入 onSurfaceChanged()的定义:
override fun onSurfaceChanged(p0: GL10?, width: Int, height: Int) {GLES20.glViewport(0,0,width,height)Matrix.perspectiveM(projectionMatrix,0,45f,width.toFloat()/height.toFloat(),1f,10f)Matrix.setIdentityM(viewMatrix,0)Matrix.translateM(viewMatrix,0,0f,-1.5f,-5f)Matrix.multiplyMM(viewProjectionMatrix,0,projectionMatrix,0,viewMatrix,0)}
这是一个标准定义,它用一个常规的透视投影和视图矩阵把物体放在正确的空间中。让我们为onDrawFrame()加入如下定义完成这个渲染器:
override fun onDrawFrame(p0: GL10?) {GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)var currentTime = (System.nanoTime() - globalStartTime) / 1000000000fredParticleShooter.addParticles(particleSystem,currentTime,5)greenParticleShooter.addParticles(particleSystem,currentTime,5)blueParticleShooter.addParticles(particleSystem,currentTime,5)particleProgram.useProgram()particleProgram.setUniforms(viewProjectionMatrix,currentTime)particleSystem.bindData(particleProgram)particleSystem.draw()}
每次一个新帧被绘制时,我们都计算当前时间并把它传给着色器。它会告诉着色器粒子自从被创建后移动了多远。我们也为每个喷泉生成了5个新的粒子,然后用粒子着色程序绘制这些粒子。
运行一下这个程序,几秒钟后,它应该看上去如下图所示。
我们现在有了一个能工作的系统,随着粒子运行时间的变化,我们会看到它由明变暗的效果,但是它看起来还是很奇怪,不是吗?当粒子向上移动时,它们不应该扩散开么?因为重力的作用,它们不应该向下回落么?接下来我们要解决这些问题及其他更多的问题。
扩散粒子
我们要做的第一件事是扩散粒子,我们也将变化每个粒子的速度,让每个粒子喷泉有更多的变化。让我们回到ParticleShooter类,将其改造为如下代码:
class ParticleShooter1(var position:Point, var direction:Vector, var color:Int, var angleVariance:Float, var speedVariance:Float) {var rotationMatrix = FloatArray(16)var directionVector = FloatArray(4)var resultVector = FloatArray(4)init {directionVector[0] = direction.xdirectionVector[1] = direction.ydirectionVector[2] = direction.z}
每个发射器都将有一个角度变化量,用来控制粒子的扩散,以及一个速度变化量,用来改变每个粒子的速度。我们也有一个矩阵和两个向量,因此,我们可以使用Android的Matrix类做些数学运算。
现在,我们只需要更新addParticles(),应用角度和速度的变化量。按如下代码更新 for循环的内容:
Matrix.setRotateEulerM(rotationMatrix,0,(Random.nextFloat() - 0.5f)*angleVariance,(Random.nextFloat() - 0.5f)*angleVariance,(Random.nextFloat() - 0.5f)*angleVariance)Matrix.multiplyMV(resultVector,0,rotationMatrix,0,directionVector,0)var speedAdjustment = 1f + Random.nextFloat() * speedVariancevar thisDirection = Vector(resultVector[0] * speedAdjustment,resultVector[1] * speedAdjustment,resultVector[2] * speedAdjustment)particleSystem.addParticle(position,color,thisDirection,currentTime)
要改变发射角度,我们使用Android的Matrix.setRotateEulerM()创建一个旋转矩阵,它会用angleVariance的一个随机量改变发射角度,其单位是度。接下来我们把这个矩阵与方向向量相乘得到一个角度稍小的旋转向量。要调整速度,我们把方向向量的每一个分量都与相同的speedVariance的随机调整值相乘。一旦完成这些,我们就通过调用particleSystem.addParticle()添加这个新的粒子。
现在需要更新ParticlesRenderer,调整新的构造函数参数。修改onSurfaceCreated(),以便粒子发射器可以按如下方式创建出来:
override fun onSurfaceCreated(p0: GL10?, p1: EGLConfig?) {GLES20.glClearColor(0.0f,0.0f,0.0f,0.0f)val angleVarianceInDegrees = 5fval speedVariance = 1f;particleProgram = ParticleShaderProgram(context)particleSystem = ParticleSystem(10000)globalStartTime = System.nanoTime()val particleDirection = Vector(0f,0.5f,0f)redParticleShooter = ParticleShooter(Point(-1f,0f,0f),particleDirection, Color.rgb(255,50,5),angleVarianceInDegrees,speedVariance)greenParticleShooter = ParticleShooter(Point(0f,0f,0f),particleDirection, Color.rgb(25,255,25),angleVarianceInDegrees,speedVariance)blueParticleShooter = ParticleShooter(Point(1f,0f,0f),particleDirection, Color.rgb(5,50,255),angleVarianceInDegrees,speedVariance)}
我们的系统已经准备好了,每个粒子喷泉都有一个5度的发射角变化量以及一个1单位的发射速度变化量。继续运行这个应用看看我们这次看到了什么。它看起来应该如图下所示。
看起来效果好多了,现在我们需要添加重力把那些粒子拉回地面。
添加重力
任何上升到空中的东西都会掉下来因为地球有吸引力。牛顿受到苹果从树上落下的启发而发现了重力效应,他也因此而出名;如果我们也给粒子加入重力效应,效果看起来会更好。
在地球上,每个人都感受到9.8m/s²的加速度,如果我落的时间越长,它的速度就越快。通过给顶点着色器加入一个容易地在代码中实现这个效果。打开particle_vertex_shader.glsl,在v_ElapsedTime赋值语句之后加入如下代码行:
float gravityFactor = v_ElapsedTime * v_ElapsedTime / 8.0;
它通过应用重力加速度公式和粒子运行时间的平方值计算得到一个加速重力因子;我们还把它除以8以弱化这个效果。数值8是被任意使用的:可以使用其他任何数值,同样也能获得不错的效果。现在需要给当前的位置应用这个重力因子,在currentPosition赋值语句之后给顶点着色器加入如下代码:
currentPosition.y -= gravityFactor;
让我们再次运行这个应用,看看会发生什么。它看起来应该如图下图所示所示。
现在我们看到每个向上移动的粒子都渐渐慢下来,并最终向地面回落。我们还可以让它更好看:一些较暗的粒子覆盖了那些较明亮的,这看起来有点奇怪。
累加混合技术混合粒子
当我们在OpenGL中实现各种效果时,我们不得不经常回想我们要努力仿制的那个效果。如果把这三个粒子流想象成一个喷射的烟花,就像我们在烟花表演上看到的一样,那我们应该期望这些粒子可以发光;粒子越多,它们就应该越亮。模仿这个效果的方法之一是使用累加混合技术。
让我们使能混合技术,在onSurfaceCreated()方法里为ParticlesRenderer类加入如下代码:
GLES20.glEnable(GLES20.GL_BLEND)GLES20.glBlendFunc(GLES20.GL_ONE,GLES20.GL_ONE)
就是它!我们首先要使能混合技术,然后设置混合模式为累加混合。为了更好地理解这是怎么工作的,让我们看一下OpenGL的默认混合公式:
输出=(源因子源片段)+(目标因子目标片段)
在OpenGL里,混合技术的工作原理是把片段着色器的结果和已经在帧缓冲区中的颜色进行混合。源片段的值来自于片段着色器,目标片段的值就是已经在帧缓冲区中的值,源因子和目标因子的值是通过调用glBlendFunc()配置的。在我们刚刚添加的代码中,调用glBlendFunc()时,我们把每个因子都设为GL_ONE,它把混合公式变为如下的公式:
输出=(GL_ONE源片段)+(GL_ONE目标片段)
通过这个混合模式,来自片段着色器的片段就被累加到已经存在于屏幕上的片段了,这就是为什么它叫累加混合技术。还有其他更多的混合模式,可以在Khronos网站上在线查看。
我们的粒子现在看上去被点亮了,因为它们混合在一起了。我们还要记住OpenGL限定每一个颜色分量的值,因此,如果我们给纯绿色加上纯绿色,得到的还是纯绿色。然而,如果我们只把一小点的红色累加到那个绿色上足够多次,实际上也会偏移它的色调,最终得到黄色;再累加一点蓝色到那个黄色足够多的次数,我们就会看到白色。
考虑到OpenGL的限值特性,我们可以实现一些优美的效果。比如,在下图中,我们的红色烟花喷泉在它的最亮处实际上有点发黄,这是因为我们给它的基色上加入了一点绿色和稍少一点的蓝色。
自定义点的外形
你可能已经注意到了,这些点被渲染为小的四方块,其每条边上的像素数量都等于gl_PointSize的值。我们实际上可以使用另一个特殊的 OpenGL变量 gl_PointCoord自定义这些点的外形。对于每个点,当调用片段着色器时,我们都会得到一个二维的gl_PointCoord坐标空间,在每个轴上,其分量的范围都是从0到1,其取值依赖于点上的哪个片段当前正在被渲染。
要知道这是怎么工作的,我们首先要使用gl_PointCoord把片段绘制为圆,而不是方形。我们怎么做呢?渲染每个点时,相对于gl_PointCoord上的每个轴来说,其片段的位置范围都是从0到1,因此,我们把点的圆心放在(0.5,0.5),其每条边都有0.5单位的空间。换句话说,点的半径是0.5。要绘制一个圆,我们所要做的就是只绘制那些位于半径内的片段。
首先让我们调高点的大小,以便使它更容易被看到。将 particle_vertex_shader.glsl中的gl_PointSize更新为如下值:
precision mediump float;
varying vec3 v_Color;
varying float v_ElapsedTime;void main(){float xDistance = 0.5 - gl_PointCoord.x;float yDistance = 0.5 - gl_PointCoord.y;float distanceFromCenter = sqrt(xDistance*xDistance + yDistance*yDistance);if(distanceFromCenter > 0.5){discard;} else {gl_FragColor = vec4(v_Color / v_ElapsedTime,1.0);}
}
用这种方法把一个点绘制为圆,其开销有点大,但是它能工作。它的工作原理是:对于每个片段,我们使用勾股定理计算其与圆心的距离。如果那个距离大于0.5单位的半径,那么当前的片段就不是圆的一部分,我们还使用了特殊的关键词“discard”告诉OpenGL丢掉这个片段;否则,我们还像以前一样绘制这个片段。
让我们再次运行这个应用;它看上去应该如下图所示(去掉混合可能会更容易观察)。
把每个点绘制为一个精灵
我们刚刚学过的技术是可行的,但是有时候纹理更有效。使用相同的gl_PointCoord和纹理,实际上可以把每个点绘制为一个点精灵。对于每个粒子,我们将改动粒子着色器,让它使用下图所示的纹理。
要实现本项目中的纹理,首先更新片段着色器,加入如下uniform:
uniform sampler2D u_TextureUnit;
去掉我们在前一节加入的画圆的逻辑,按如下代码更新 gl_FragColor的赋值语句:
gl_FragColor = vec4(v_Color / v_ElapsedTime,1.0) * texture2D(u_TextureUnit , gl_PointCoord);
它会使用gl_PointCoord作为纹理坐标在每个点上绘制一个纹理。纹理的颜色会与点的颜色相乘,这样就可以用与以前一样的方式将这些点染上颜色。
我们现在需要给ParticleShaderProgram加入新的uniform。首先加入如下成员变量:
var uTextureUnitLocation = 0
在构造函数的结尾处加入如下代码:
uTextureUnitLocation = findUniformLocationByName(U_TEXTURE_UNIT)
我们需要把 setUniforms()的签名更新为如下代码:
fun setUniforms(matrix:FloatArray,elapsedTime:Float,textureId:Int){
我们也需要在 setUniforms()的结尾处加人如下代码:
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,textureId)GLES20.glUniform1i(uTextureUnitLocation,0)
我们只需要加载这个新的纹理,就准备好运行这个应用程序了。打开ParticlesRenderer,
并加人下面的新成员变量:
var texture:Int = 0
在 onSurfaceCreated()的结尾处加入如下代码:
texture = TextureHelper.loadTexture(context,R.drawable.particle_texture)
现在我们可以在onDrawFrame()中更新调用particleProgram.setUniforms()了,如下代码所示:
particleProgram.setUniforms(viewProjectionMatrix,currentTime,texture)
最后运行效果如下图。
小结
我们已经讲述了粒子系统的一些基本内容,但事实上我们只触及了一些皮毛而已。粒子可以实现很多效果,包括逼真的火焰、雨和雪等。我们学习了如何用OpenGL的GL_POINTS模式绘制粒子,我们还进一步讨论了如何用gl_PointCoord及discard关键词自定义点的形状;也可以使用纹理更进一步定制点的外形。有了这些能力,我们可以实现一些相当优美的效果。