« Lua binding 的一些方法 | 返回首页 | 粒子管理器的 C++ 封装 »

粒子系统的设计

这几天在重构引擎中的粒子系统。之前用 lua 做了个原型,这次用 C/C++ 重新实现一次。目前还是基于 CPU 的粒子系统,今后有必要再实现基于 GPU 的版本。

去年写过一篇 blog 也是谈粒子系统的 。 思路大致类似,但这次在数据结构的细节上做了一些专门的设计,有觉得还有点意思,值得写写。

首先,粒子对象本身就是一个集合了多种数据的数据块。我限制了同时最多 64K 个粒子片,这些粒子对象可以放在一块连续内存中,并且可以用 16bit 的 id 进行索引。

当粒子系统的效果比较复杂时,会有很多的属性可以自动变化。包括且不限于位置、颜色、方向、大小、速度、加速度等等。其中,加速度本质上是受力,而粒子可以受多个力的作用,重力,风,向心力(用于旋转)。

一种方案是把每个发射器以及它发射出来的粒子看成一个对象,另一种方案是把所有发射器发射出来的粒子统一看成一个整体。我倾向于后者。因为当发射器可以发射发射器时(烟花或闪电都有这方面的需求),管理起来要简单一些。在这个方案下,发射器只是粒子的一个特性(可以发射新的粒子)。

那么,每个粒子其实是若干特性的组合。使用类似 ECS 的视角去看待粒子会方便的多。

  1. 每个粒子对象的数据是由若干 Component 组合而成的。
  2. 粒子系统由若干变换构成,借用 ECS 的术语,就是由若干 System 组成;每个 System 针对一类 Component ,它对有这个 Component 的粒子做某个统一的变换。
  3. Component 可以仅仅是一个 tag 不对应真实的数据。比如 Component 有 A B C 三种实际可以对应到三个数据结构上的类型。但我们还可以拥有一个叫 AB 的 tag 表示同时拥有 A 和 B 的粒子;或是 Ac ,表示拥有 A 但没有 C 的粒子,等等。

粒子对象可以对应到 ECS 中的 Entity ,但和 ECS 框架不同,我们并不需要对外暴露 Entity ID 。这是由粒子系统的特性决定的:

每颗粒子都是自生自灭的,在出生那一刻就决定了生命过程中的所有状态变换。多个粒子间没有相互影响,更新粒子 A 的时候,不需要取出特定 B 粒子的状态,所以不需要用一个 EntityID 去固定索引特定粒子。在应用特定的计算规则时,每颗粒子都可以单独计算,不关心粒子间的计算先后次序。

针对这些特点,我们就可以设计特定的数据结构,在内存使用和计算方面获得优势。


首先,我们不应把组成粒子的多个组件在内存布局上聚合在一起(C++ 对象的传统方案);而应该按组件类型聚合多个例子。

比如,大部分粒子都有一个 float lifetime 属性,我们在内存布局上就可以把所有存活的粒子的 lifetime 平坦的聚合在一个 float 数组里。这可以获得两个好处:

  1. 如果某个粒子不需要 lifetime ,就可以不占内存。
  2. 对于一些特例(只有一个 system 操作这个属性时),方便日后做 SIMD 优化。

另外,我们需要一个每帧递减 lifetime 的 system ,这个系统遍历 lifetime 这个 Component 集合,递减每个变量。

该数据结构的设计难点在于:每个 Component 是分离的,但是在处理某个业务时,需要联合使用多个 Component 。比如,我们需要把 “速度” 叠加到 "位置" 上。我们在遍历 "速度" 时找到同一个粒子对应的 "位置",就是很麻烦的事情。

我的做法是用一个 16bit id 把它们关联起来。但和传统 ECS 的设计不同,这个 id 仅用于内部工作,不暴露给使用者。所有的 Component 都在一个线性数组中,我们的 API 可以通过 Component 的类型和在这个数组中的索引号,找到同个粒子的另一个 Component 在所属数组中的索引。

我为这个数据结构实现了一个管理器 ,它管理了之间的关系,但不管理实际的数据储存。

其核心 API 是这样的:

bool particlesystem_add(manager, n, components[])

向粒子系统添加一个粒子,这个例子由 n 个 component 构成。注意:这里并不涉及 Component 的具体数据(它不由 manager 管理),仅仅是描述 component 的类型。

如果添加成功,那么用户需要自己把对应的数据追加到自己维护的数组最后。

index particlesystem_component(manager, component, index, sibling_component)

找到 component 的索引 index 处对应的粒子的 sibling_component 在它这个类别数组中的索引。这个 API 用于找一个 component 的兄弟。

particlesystem_remove(manager, component , index)

将 component 的索引 index 处对应的粒子所有关联的组件全部删除。删除本身只做一个记号,需要等 arrange 的阶段才真正抹去。

particlesystem_arrange(manager, n, remap[], state)

整理所有粒子,将当前帧删除的粒子抹去。如果关联的数组中出现空洞,则重新排列数据,最终保证数据连续。这个 API 把重新映射关系填入用户传进去的 remap 映射表中。这个映射表会告诉用户,什么 component 的哪个索引槽位中的数据被调整到哪个新的槽位。

由于管理器不管理真正的 component 数据,所以用户需要根据 remap 自己重新调整存放 component 数据的容器内容。


具体的使用见上面代码中的 test.c ,它演示了一个极简的粒子系统,只有 lifetime 和 value 两个属性。 update 函数会递减 lifetime 属性;并将 delta 不为 0 的 value 属性减去 delta 值。

Comments

现在电脑显示器的像素密度最大也就是4*1080*1920,如果色深是32位的情况下,一帧的BMP图形传递给显示器,显示器扫描完(原来CRT的扫描电路,现在改成液晶了,但是扫描芯片没有变化)整个显示器需要大约是,4*1080/20M(这是扫面芯片晶振的频率,扫描芯片属于微控器的一种,STM32可以替换它),垂直同步信号返回时间是10毫秒,大约是,12毫秒~20毫秒。
要想突破这个像素密度,那就必须有提高扫描电路芯片的晶振频率,但是同步信号这个10毫秒几乎不可能压缩,所以280Hz的高刷屏,是不能开垂直同步的。144才是正规的高刷指标。
上面这些指标,你就算是Imax那种几十米的屏幕,也是这样的。
所以,这个限制了显卡的图形能力。

哈哈,事实上,跟你的猜测是截然相反的。粒子系统的产生的原因是向量的实例化的最早期的一种方案,向量也叫矢量,这是牛顿发明的,如果一个小场景(比如一间屋子)中所有的跟光学有关的矢量全部展开,这个计算量也会让现在最先进的计算机瘫痪,所以现在的解决方案就是光追,就跟AutoCAD一样,你不放大,我就计算0.1的精度,你放大,我就用autolisp计算到0.0001,这是一个根据用户事件现实的动态程序,光追也是一样,你的视角看到的方向,我计算你应该看到的光影效果,你看不到的角度,那个狗就不计算出来。这种简化的计算模式,不能用在多人多角度看一个屏幕的场景,也不能用在裸眼3D上。

大开眼界

发射器可以看做粒子的初始位置。

三年了,风哥的游戏引擎什么时候开源

最好奇是大量粒子的情况下,碰撞的优化方案

最好奇是大量粒子的情况下,碰撞的优化方案

666

引擎啥时候面世啊

Post a comment

非这个主题相关的留言请到:留言本