关于粒子系统的介绍这里不在赘述,有需要了解的可以自行搜索;本文具体分享一下怎么通过THREEJS打造一个粒子系统的过程,重点是思路。
简单来说粒子系统就是由许许多多个粒子(Billboard)组成,每个粒子都有其各自的属性(颜色、位置、旋转、贴图),将他们放在一起,然后不断的去改变这些属性就可以实现一些特别酷炫的效果。所谓粒子系统就是提供一个类,负责去生成、管理、渲染这些 Billboard。
在 THREEJS 中我们可以使 ParticleSystem
继承 Object3D
类,这样就可以获得Object3D
全部属性(position、rotation)等。也可以通过像操作一个节点一样,将 ParticleSystem
添加到 scene
,这样就可以让粒子系统同步跟随渲染。与此同时 ParticleSystem
还需要实现对当前粒子系统下所有粒子 ParticleItem
的管理,在每次渲染前对每个粒子进行 update
,更新他们的属性,每个粒子具体的属性如下:
export default class ParticleItem {
//粒子对应图元在 BufferAttribute 中的索引
index = 0
//RendererModule
render = null
//
startDelay = 0
//粒子生命周期
lifeTime = 0
//初始发射速度
startSpeed = 1
//初始大小
startSize = 1
//初始颜色
startColor = new Vector4(1,1,1,1)
//初始角度
startRotation = 0
//初始位置
startPosition = new Vector3(0,0,0)
//重力加速度倍数
gravityModifier = 0
//移动方向,受 ShapeModule、VelocityOverLifetimeModule、MainModule 等模块控制
moveDirection = new Vector3(0,0,0)
//是否处于活跃
active = true
//存活时间,挡这个值大于 lifeTime 时,则该粒子会被失活
currentTime = 0
//当前速度,受 VelocityOverLifetimeModule 控制
speed = new Vector3(1,1,1)
//旋转速度,受 RotationOverLifeTimeModule 控制
rotationSpeed = new Vector3(0,0,0);
...
}
在这里我们用类去描述粒子,每个粒子即为该类的一个实例(对象)。
然后新的问题出现了,
- 那么多粒子,要如何去管理这么多粒子才能使得他们按照我们预先设定的想法进行移动(或其他操作)
- 要如何去管理这些粒子的创建、删除
- 要如何去将这些 javascript 对象转换成可以用于被渲染的资源
于是这里借用了编程中重要思想:封装,将这些对粒子的控制拆分成一个一个单独的功能,封装到不同的模块中,这些模块都具备有一些相同的方法(钩子函数)。这些模块被 ParticleSystem
统一管理,在每次更新进行到特定的环节,触发对应的钩子,传入ParticleItem
实例,就可以修改 ParticleItem
上的属性。
例如:
EmissionModule
模块单独负责创建粒子,更准确来说负责计算当前帧需要创建的粒子数量
ColorOverLifetimeModule
模块负责计算粒子的颜色
...
对粒子系统来说,
ParticleSystem
并非最小单位,但是对于场景来说却是不可分割的
初始化 ParticleSystem
//存放各种模块
this.statusModules = [];
//主模块
this.mainModule = new MainModule(data.main);
...其他模块
//创建时钟
this.clock = new Clock();
//最大粒子数 = 当前粒子系统持续时间 * 每秒发射粒子数量
this.totalParticleCount = Math.min(this.mainModule.duration * this.emissionModule.secondParticle, this.mainModule.maxParticles);
//Render模块
this.render = new RendererModule(this.totalParticleCount, data.render);
this.render.mesh.layers.mask = this.layers.mask;
this.add(this.render.mesh);
//粒子缓冲池
this.itemPool = [];
//当前激活状态粒子
this.activeItemArray = [];
this.removeItemArray = [];
this.totalTime = 0;
在初始化过程中:
- 会创建各种上述我们提到的功能模块,其中包括
MainModule
和RendererModule
两个必要的模块,后面会介绍他们的功能。 - 创建缓存池,存放
ParticleItem
实例,缓存池的大小 = 每秒产生粒子数量 * 粒子系统单次持续时间。
-activeItemArray
用于存放当前处于激活状态的粒子
缓存池的大小上限为当前粒子系统所能创建
ParticleItem
实例的最大数量,并非实际需要的数量。例如:一个粒子系统粒子生命周期(main.startLifeTime)为5秒,每秒产生粒子数量(emission.rateOverTime)为15,持续时间(main.duration)为1秒,则缓存池大小为 15 * 1 = 15;而粒子的 lifeTime 为5秒,也就是说粒子被创建后5秒才会被缓存池回收,那么在这期间缓存池中为空,没有可用的粒子,也就是说这个粒子系统有5秒的冷却时间
更新
在WebGLRenderer
执行 render 方法进行渲染前,渲染器会 traverse
遍历 scene 下所有的对象Object3D
,执行 onBeforeRender
方法,在ParticleSystem
实现这个方法,在每一次渲染前会自动调用update
方法
onBeforeRender(renderer, scene, camera){
this.activeItemArray.map(item => {
//更新相机,用于计算 billboard
item.camera = camera
})
this.update()
}
在ParticleSystem.prototype.update
中我们需要做以下几件事情
- 创建新粒子
- 更新激活粒子
- 失活过期粒子
update() {
let clockTime = this.clock.getDelta();
//EmissionModule 负责计算当前帧需要产生的新粒子:根据每秒产生粒子数量rateOverTime可以计算得出两个粒子产生间隔时间,然后在每一次更新时与 delta 比较,即可得出当前需要产生多少新的粒子
this.emissionModule.update(clockTime, null);
let newParticle = this.emissionModule.createParticleCount;
if (newParticle > 0) {
for (let i = 0; i < newParticle; i++) {
//新粒子会从缓存池中拿去,当缓存池中为空时表示无粒子可用,放弃更新
let item = this.getItemByPool();
if (item == null) break;
//根据当前粒子系统设定的shape,计算得到新粒子的发射起点位置(startPosition)和方向(moveDirection)
this.shapeModule.update(clockTime, null);
item.setStartPosition(this.shapeModule.startPosition);
item.moveDirection.copy(this.shapeModule.moveDirection);
item.setActive(true);
//初始化粒子数据
for (let moduleIndex = 0; moduleIndex < this.statusModules.length; moduleIndex++) {
if (this.statusModules[moduleIndex].isActive) {
//调用各个模块的初始化方法,给粒子设置初始状态
this.statusModules[moduleIndex].initParticleItem(item);
this.statusModules[moduleIndex].reset();
}
}
//设置为激活状态
this.activeItemArray.push(item);
this.shapeModule.reset();
}
}
this.emissionModule.reset();
for (let i = 0; i < this.activeItemArray.length; i++) {
let particleItem = this.activeItemArray[i];
for (let moduleIndex = 0; moduleIndex < this.statusModules.length; moduleIndex++) {
if (this.statusModules[moduleIndex].isActive) {
//调用各个模块的更新方法,给粒子更新状态
this.statusModules[moduleIndex].update(clockTime, particleItem);
this.statusModules[moduleIndex].reset();
}
}
//更新粒子active状态、更新粒子的位置和旋转
particleItem.update(clockTime);
//如果粒子active为false,则对其进行回收
if (!particleItem.active) {
this.removeItemArray.push(i);
this.pushItemToPool(particleItem);
}
}
for (let i = 0; i < this.removeItemArray.length; i++) {
this.activeItemArray.splice(this.removeItemArray[i] - i, 1);
}
this.removeItemArray.splice(0, this.removeItemArray.length);
}
从上面过程可以看出
EmissionModule
和ShapeModule
仅作用于粒子的创建阶段,不参与粒子的更新。其他的模块会参与每次的update
,并更新粒子的 速度、旋转速度。然后调用particleItem.updae
方法根据速度和时间,计算出最新的position
和rotation
,并调整旋转角度,使之看向相机。最后更新对应图元position
对应Attribute
渲染
回到之前提到问题:最后如何将更新完成后的粒子变成可渲染的资源。
这里有两种解决方案:
- 给每个粒子对应创建一个
Mesh
,每次更新这个mesh
的位置 - 创建一个
Mesh
,将当前粒子系统下对应所有粒子合并到一个BufferGeometry
,记录每个粒子的索引,之后根据这个索引更新BufferGeometry
的顶点属性
显然第二种方式更加能够节省性能
具体做法:
在ParticleSystem
初始化阶段创建RendererModule
,后者负责创建实际用于被渲染的Mesh
。RendererModule
需要实现以下几点:
- 根据配置,选择对应的 shader 以及混合方式,加载默认纹理,根据这些创建材质
- 根据粒子缓存池大小创建
geometry
- 提供一系列方法用于修改顶点缓存区数据