粒子管理器的 C++ 封装
这篇接着上一篇 粒子系统的设计。
TL;DR 在花了一整个晚上用 C++ 完成了这一块的功能后,我陷入了自我怀疑中。到底花这么多精力做这么一小块功能有意义么?强调类型安全无非是为了减少与之关联的代码的缺陷,提高质量;但代码不那么浅显易懂却降低了质量。
我们用 C 实现了一个基于 ECS 结构的粒子系统的管理器,代码 psystem_manager.h 在这里。
先来回顾一下设计:在这个粒子系统中,我期望把粒子对象的不同属性分开管理。
即:传统的面向对象的数据结构中,一个对象 particle 可以有很多属性 a,b,c 。通常是用一个结构体(或类)静态定义出来的,这些属性也可以看作是 a b c 组件,它们构成了粒子对象。而在 ECS 结构中,我们在每个时间点,并非去处理一个对象的多个属性,而是处理同一个属性的多个对象。所以,我们最好按属性分类将多个对象的同一属性聚合起来,而不是按对象,把同一对象的不同属性聚合在一起。
这是因为,在处理单个属性时,往往并不关心别的属性。比如,我们在递减生命期,处理生命期结束的对象时,关心的仅仅是生命期这个属性;在处理粒子受到的重力或其它力的影响时,我们只关心当前的加速度和速度;在计算粒子的空间位置时,只关心上一次的位置和瞬间速度;而在渲染时候,无论是生命期、加速度、速度,这些均不关心。
当数据按属性聚合,代码在批量处理数据时,连续内存对 cache 友好,即使属性只有一个字节,也不会因为对齐问题浪费内存。同一属性的数据尺寸完全相同,处理起来更简单。而且粒子对象相互不受影响,我们只是把同一个操作作用在很多组数据上,次序不敏感。非常适合并行处理。
更重要的是,不同类型的粒子需要自由的根据需要组合属性和行为。有的粒子有物理信息参与刚体碰撞运算,有的则只需要显示不需要这个信息;有的粒子有颜色信息,有的不需要有;有的粒子是一个面片,有的却是一个模型,拥有不同的材质。这导致粒子对象包含的信息量是不同的。及时拥有同一属性,作用在上面的行为也可能不同:例如同样是物理形状信息,可能用于刚体碰撞,改变运动轨迹,也可能只是为了触发一下碰撞事件。
在传统的面向对象的方式中,常用多态(C++ 的虚函数)来实现,或者有大量的 if else switch case 。
如果能按组件和行为聚合,那么就能减少大量的分支。每个粒子的功能组合(打开某个特性关闭某个特性)也方便在运行时决定,而不用生成大量的静态类。
但换种方式组织数据也有难点:原本用偏移量和直接的指针聚合在一起的单一对象,被打破分散在了多个数据块中。同一个对象的不同部分建立联系会比较麻烦。这种需求会出现在两个场合:
- 对象删除时,需要找到所有的组件删除。
- 多个组件有关联处理时,需要从一个组件 A 找到同一个粒子上的组件 B 。
最简单粗暴的方法是还是建一个空的根对象,让它对所有的组件都保留一个指针;然后没有组件都保留一个对这个空对象的指针。在组件比较零碎时,这些相互引用的指针会造成大量的内存占用,甚至超过组件本身的数据尺寸。
我之前写的管理器模块就是实现这样的数据结构,并尽可能的减少额外内存的使用,同时、同一粒子间的的组件相互引用能保持 O(1) 的访问时间。为了让这个管理器的功能内聚,在设计的时候,我考虑了几点:
- 管理器只管理关系,不管理数据内存,内存在外部由别的模块负责。
- 外部数据被视为可用连续内存块管理,不需要外部记录任何形式的引用值(指针或 id 的形式都不需要)。
- 不强制要求外部数据在连续内存块上,由外部自己决定如何储存。
- 外部数据推荐用 POD 结构,但不强制。
- 可以在迭代的过程中删除对象,不破坏迭代过程。让删除仅仅是做标记,基于粒子系统的特点,即使是刚被删除的对象中的组件,在其上做处理也是无害的。
- 整理被删除对象放在统一的地方一次处理,在保持外部内存块连续的基础上,做最少的数据移动。外部数据移动的过程不用 callback 的形式驱动。
我在用 C 语言实现完管理器后,写了一个简单的使用案例 (见 test.c )。同事理解了思路后,接手来做其它部分。他倾向于用 C++ 来完成后面的编写,理由是需要类型安全。
我个人虽然不太喜欢用 C++ 做设计,不过我的工作原则是,在保证模块划分清晰,接口明确的前提下,具体实现尊重实现者的意愿。爱用什么写就用什么写。
做后续的工作的起点,是需要做一个外部的数据容器集,可以存放不同的组件(属性)数据。也就是说,有若干的组件数组,每个放一个特定类型的组件,单个数组里有整个系统内拥有这个属性的所有粒子对象的该组件。
看起来会是这样的:
struct particle_manager *manager; arrayType1 t1; arrayType2 t1; arrayType3 t3; arrayType4 t4; ...
这就保存了整个粒子系统中的所有数据,而关联关系的数据在之前实现的 particle_manager
结构中。加起来就是全部。
显然,把 Type1 到 TypeN 的 array (实际用 std::vector 实现) 这么平坦的放在这里,会造成大量的重复代码。因为外部要处理 manager 模块的 arrange remap 信息(在删除对象后数据重排列)时,处理代码并不关心具体类型。所以很自然的,数据结构就演变为:
attribute * attribs[MAXCOMPONENTS]; struct particle_manager *manager;
给这些不同类型的数据容器加上一个基类 attribute ,保存一个基类指针的数据就够了。这样方便处理。
我们在实现这个系统(给关联关系的管理器增加实际数据的管理)时,需求有三:
- 添加一个由若干组件构成的粒子:需要分别给不同的属性容器追加数据,然后告诉管理器增加一个粒子。
- 删除任何一个组件时,同时删除和这个组件关联的组件(同一粒子对象)。管理器会报告数据块如何重新排列,接下来需要按这个信息重组数据块。
- 迭代特定的组件数组。依次取出数组中的每个组件数据。
对于需求 3 ,由于实际上我们就是用平坦内存(std::vector)储存的,迭代本身是非常廉价的。但是以上结构直接使用是类型不安全的。C++ 通过模板技术可以实现类型安全并不增加额外的成本。这是实现者选用 C++ 封装的动机。
我们可以提供这样一个 api :
vector<类型>& particle_system::attrib<类型>();
使用者正确的填写了组件的类型,就能返回对应的容器引用。它干的事情其实只是通过类型 ID 找到 attribs[] 中对应的那个 attrib * ,取出里面的引用。这个功能非常简单,C++ 带来的好处就是强类型避免犯错。如果类型没有写对,不能通过编译,而不是发生运行时错误(如果是 C 接口,恐怕就是使用类型 ID 而不是类型本身,也只能报告运行时错误)。
另外,还有一些衍生的需求。属性数据类型可以被分为三类:
- POD 的数据结构。
- 原生数据类型(例如 float int 这些)或其它库提供的基础数据结构(例如 float3 matrix 等)。
- 复杂的接口(带虚表的对象)。
对于 3 ,希望用 raw 指针而不是智能指针(智能指针其实就是 2 )。这是因为依然希望最终在处理整块数据的数据,面对的是连续内存块上的有明确数据布局的数据。raw 指针会比智能指针对象更清晰。
但这给 C++ 封装带来了一点挑战。因为容器可以是 container<类型> 也可以是 container<类型*> 。再重排列的算法上也有一点差别:raw 指针需要更小心的处理。这会用到模板的偏特化来解决。
另外,还需要从一个组件找到关联的组件数据。我希望无论是容器里是值对象还是 raw 指针,接口全部返回组件的指针,并用 nullptr 表示没有关联的组件。不同的数据储存形式也加大了封装层的复杂度。
同事在实现的时候遇到了一些语言技术细节上的困难(毕竟我们并不常用 C++ 开发),我们一起讨论,这激起了我的一点兴趣。我这些年都没有实际用 C++ 创作过新代码(但一直有阅读和维护第三方 C++ 项目,并不算陌生),虽然我对 C++ 新标准不算熟悉,但还是勉强能写写。这次的需求本质上并不复杂,只是用 C++ 模板封装一个比较简单的功能(给粒子管理器在关系管理的基础上增加数据管理),并提供类型安全。我决定自己也试着实现一个版本。
我个人的预期是这样的:
- 少用不必要的复杂模式,能解决问题就够了。目的就是达到类型安全,在使用的时候不再写显式的类型转换。写错类型时,在编译器就能检查出来。
- 头文件只暴露最少的信息,不在头文件中暴露模板方法的实现,尽量不在头文件中引用标准库或及其库,避免编译时间的膨胀。
- 使用模板的目的是用模板去约束类型,并减少重复的信息,易于维护。不追求语法上的技巧。不强求用最好(最新)的方法。
最终我花了一整个晚上才实现出来。代码不算太多,功能代码 100 来行,再加上 100 来行的简单使用案例(和之前的 C 版本基本功能一致)。我编写 C++ 模板代码不太熟练,中间编译错误的反复修正耽误了不少时间。但是,还是有大比例的时间在思考怎么用模板工具去绕出我想要的功能:类型约束检查。
最后,我们可以用一个简单的 for 语言迭代特定的组件数组。
void particle_system::update_life(float dt) { int index = 0; for (auto &life : attrib()) { printf("lifetime: %f\n", life); life -= dt; if (life <= 0) { printf("REMOVE %d\n", index); remove (index); } ++index; } }
这段代码是迭代一个 float 的 life 数组,把其中每个 float 递减 deltaT 。如果 life 小于等于 0 ,就标记对象为删除。
其中迭代支持标准的 C++ 形式 for (auto &life : attrib<lifetime>())
,除去类型转换处理,它本质上就是在遍历一个 float [] 。
标记对象为删除也只需要 remove<lifetime>(index)
此处的 index 为 life 组件在 lifetime 属性数组中的当前序号。
另一个使用案例见 particle_system::update_print
的实现。
void particle_system::update_print() { int n = size(TAG_PRINT); for (int i = 0;i<n;i++) { const value *v = sibling<value>(TAG_PRINT, i); if (v) { printf("Value = %d ", v->value); } const object *obj = sibling<object *>(TAG_PRINT, i); if (obj) { printf("Object = %d ", obj->value()); } printf("\tParticle %d\n", i); } }
它用 TAG_PRINT
遍历所有的有这个 tag 的对象。因为这是个虚拟 tag 并没有对应的组件,所以在这里并没有对应的数组容器。我们用 size(TAG_PRINT)
取出个数,然后 for i = 0 .. n 即可。在迭代中,可用 sibling<类型> 取到关联的组件。
最后是添加粒子的 api :
add({ push_back(lifetime(20)), push_back(value { 0, 1 }), push_back(new object(42)), TAG_PRINT, });
这些 push_back
会根据类型把组件添加到正确的容器中。同时, add 会将信息组织起来向管理器注册新的粒子。此处还可以打上虚拟 tag ,比如 TAG_PRINT
。
但实际上,这种用法的实际用途有限。因为我们最终并不会使用 C++ 代码静态的创建固定的粒子。而是发射器根据预定义的数据结构去分步创建。
在我花了一晚上时间(前后改了三版)最终完成了现在的版本后,我陷入了深深的怀疑中。仅仅是为了增加更严格的类型约束而消耗很多的精力到底有什么意义。其实我并没有完成太多实际的功能。当然代码量不大,算不上臃肿,但精力的开销却是能直接感受到的。大比例的代码和时间都是在想办法(通过不那么直观的形式)告诉编译器,这里应该增加怎样的约束,而不是完成运行时的某个特性。
另外,还不断的需要和内聚度做斗争(为此我调整了几次代码)。我期望不要把过多实现细节暴露在外面(尽量少在头文件中暴露出内部细节)。而 C++ 在这方面提供的语言层面的帮助却比较少:如果想定义方法针对类中的数据做操作,就需要把方法定义在类声明中暴露在头文件里。模板的前置声明写起来也很繁琐。
即使抛开不太熟悉语言工具这个因素,我认为经验丰富其实也并不会减少太多精力去完成它。而代码质量却变得不那么显而易见。我只能说,现在的代码中冗余的信息并不算多,绝大对数代码行都在履行预设的任务。没有太多的信息重复。但是,到底够不够简单易维护却是很难判断的。至少一眼看上去这百来行代码并不是特别容易理解。这是违背我的美学的。可能应归结到我的能力和经验欠缺上,C++ 代码暂时写不出更好。
但更好(简单易理解)的门槛是不是有点高?