THREEJS 粒子系统

关于粒子系统的介绍这里不在赘述,有需要了解的可以自行搜索;本文具体分享一下怎么通过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;

在初始化过程中:

  • 会创建各种上述我们提到的功能模块,其中包括MainModuleRendererModule两个必要的模块,后面会介绍他们的功能。
  • 创建缓存池,存放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);
    }

从上面过程可以看出 EmissionModuleShapeModule 仅作用于粒子的创建阶段,不参与粒子的更新。其他的模块会参与每次的update,并更新粒子的 速度、旋转速度。然后调用 particleItem.updae 方法根据速度和时间,计算出最新的positionrotation,并调整旋转角度,使之看向相机。最后更新对应图元position对应Attribute

渲染

回到之前提到问题:最后如何将更新完成后的粒子变成可渲染的资源。
这里有两种解决方案:

  • 给每个粒子对应创建一个Mesh,每次更新这个mesh的位置
  • 创建一个Mesh,将当前粒子系统下对应所有粒子合并到一个BufferGeometry,记录每个粒子的索引,之后根据这个索引更新BufferGeometry的顶点属性

显然第二种方式更加能够节省性能
具体做法:
ParticleSystem初始化阶段创建RendererModule,后者负责创建实际用于被渲染的MeshRendererModule需要实现以下几点:

  • 根据配置,选择对应的 shader 以及混合方式,加载默认纹理,根据这些创建材质
  • 根据粒子缓存池大小创建geometry
  • 提供一系列方法用于修改顶点缓存区数据
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容