粒子系统的设计
这几天在重构引擎中的粒子系统。之前用 lua 做了个原型,这次用 C/C++ 重新实现一次。目前还是基于 CPU 的粒子系统,今后有必要再实现基于 GPU 的版本。
去年写过一篇 blog 也是谈粒子系统的 。 思路大致类似,但这次在数据结构的细节上做了一些专门的设计,有觉得还有点意思,值得写写。
首先,粒子对象本身就是一个集合了多种数据的数据块。我限制了同时最多 64K 个粒子片,这些粒子对象可以放在一块连续内存中,并且可以用 16bit 的 id 进行索引。
当粒子系统的效果比较复杂时,会有很多的属性可以自动变化。包括且不限于位置、颜色、方向、大小、速度、加速度等等。其中,加速度本质上是受力,而粒子可以受多个力的作用,重力,风,向心力(用于旋转)。
一种方案是把每个发射器以及它发射出来的粒子看成一个对象,另一种方案是把所有发射器发射出来的粒子统一看成一个整体。我倾向于后者。因为当发射器可以发射发射器时(烟花或闪电都有这方面的需求),管理起来要简单一些。在这个方案下,发射器只是粒子的一个特性(可以发射新的粒子)。
那么,每个粒子其实是若干特性的组合。使用类似 ECS 的视角去看待粒子会方便的多。
- 每个粒子对象的数据是由若干 Component 组合而成的。
- 粒子系统由若干变换构成,借用 ECS 的术语,就是由若干 System 组成;每个 System 针对一类 Component ,它对有这个 Component 的粒子做某个统一的变换。
- 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 数组里。这可以获得两个好处:
- 如果某个粒子不需要 lifetime ,就可以不占内存。
- 对于一些特例(只有一个 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
Posted by: rawa459 | (9) April 30, 2022 03:37 AM
Posted by: rawa459 | (8) April 29, 2022 04:31 PM
Posted by: chemin | (7) December 15, 2020 01:13 PM
Posted by: r2 | (6) December 11, 2020 02:56 PM
Posted by: 无名 | (5) November 27, 2020 09:48 AM
Posted by: kity-zhang | (4) November 26, 2020 05:26 PM
Posted by: kity-zhang | (3) November 26, 2020 05:25 PM
Posted by: 虫族 | (2) November 25, 2020 07:46 AM
Posted by: coco | (1) November 23, 2020 09:52 AM