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
  • 提供一系列方法用于修改顶点缓存区数据
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,186评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,858评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,620评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,888评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,009评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,149评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,204评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,956评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,385评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,698评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,863评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,544评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,185评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,899评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,141评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,684评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,750评论 2 351

推荐阅读更多精彩内容