我们引擎中使用的数学库已经修修补补很久了。期间积累了很多想法。最近在对引擎做性能优化,把一些找到的热点系统用 C/C++ 改写,顺便就重构了一下数学库,让它更好的兼顾 Lua API 和 C API 。
上一次对数学库的改进是三年前的事情了。这三年的使用,我们发现,基于一个栈的 DSL 虽然可以减轻 Lua 和 C 之间的通讯成本,但是使用起来并不方便。大部分时候,我们还是倾向更传统的接口:一次数学运算调用一次函数。而高复杂度的数学运算完全可以放在独立的 C 模块中完成。结合 ECS 系统,我们可以在 C side 集中批量处理相同但数量巨大的数学运算。
我们在很早以前就放弃了原来设计的 DSL ,只使用数学库的部分功能。趁这次重构,我打算把这些已经废弃的部分彻底删掉,并重新设计底层的数据结构,让它能更好的适应其核心特性。
我认为我们的数学库最核心的特性是:所有的数学对象,包括矩阵、向量、四元数都是不可改写的值对象,有一致的外观。我将它表达为 64bit 的 id ,这样方便 Lua (或别的语言)binding 。
做 Lua binding 时,数学对象 id 是一个 lightuserdata 而非传统库用的重量级的 userdata 。这样用起来的代价和普通的数字相比,并无明显的额外负担。
即使不在 Lua 中使用,直接由 C/C++ 运用时,一个 64bit 的 id 也好过更大(且尺寸不等)的数据结构或智能指针的等价物。在 C/C++ 中使用,同样可以把矩阵这种重量的数据块当成和整数一样的轻量值。
做到这一点,最难的是对象生命期管理。我假设在使用中,大部分的数学对象都是即用即弃的,一道复杂的运算过程,只有最后一个环节被传入其它模块中。而其它第三方模块(比如物理、动画、渲染)通常会有自己的解决方案,并不依赖调用者来维持数学对象(矩阵等)的生命期。
所以,我们的数学库默认是在一块固定的临时内存块上分配数学对象的储存空间的。新的实现中,这块临时内存最多是 256 页,每页 1024 个 float4 的空间,最多可以保存 64K 个矩阵或 256K 个 vector4 。临时空间在渲染帧之间清理干净;同一帧内,对象是不会删除,一直有效的。
分配临时对象使用最简单的 bump allocator ,创建一个新对象 id 只需要一次加法,所以几乎和栈上分配同样高效,故而我们不再需要考虑把数学对象放在栈上还是堆上的问题。
对于需要持久引用的对象,可以把临时对象区的数据复制到一个永久对象区。和之前的实现不同,永久对象区的对象使用了部分的引用计数,这样可以减轻多次引用的代价。
对应的 api 是 mark 和 unmark ,可以理解为,如果一个 id 你想永久引用,那么就调用 mark(id) 生成一个新的永久 id 记住,当你不用的使用再调用 unmark(id) 放弃引用。当传递引用的时候,如果接收方也想引用它,同样需要再次 mark ;而实现可以选择内部增加引用,也可以额外再生成一个副本。
因为我们把所有的 id 都视为不可改写的数学对象,所以选择引用同一块内存还是复制数据得到新的副本,对使用者来说都是等价的。
unmark 不应被视为 delete 或 release ,因为它并不立刻回收内存,所以 id 被 unmark 后,还可以继续使用到帧末。这样,对于应用传递来说,并不需要刻意在传递引用时刻意调用 mark 来保证 id 的有效性。(这和传统的智能指针的引用方案是不同的)
这次重构,我增加了两个重要的新特性。这是在我们几年的使用经验中总结出来非常必要的功能。
其一,增加了 NULL 类型。接口中应出现数学对象的地方都可以传一个 NULL 对象,以和正常的矩阵等对象区别开。
这会让接口设计更具弹性。在永久引用的数据结构中,也可以把 NULL 作为默认值,方便做某些优化。比如,对于合成 SRT 到一个矩阵的接口,允许传 S/R/T 的任意参数为 NULL,减少计算量。
其二,增加数组类型。准确的说,任何数据对象都是数组,只不过默认数组长度为 1 。一个矩阵数组可以被视为单个矩阵(数组的第一个元素);也可以用 index 这个(轻量) api 取出数组中的某个特性元素。
有了这个特性,可以更方便的表达类似 AABB (两个向量),视锥体(六个向量)等对象;C API 设计时也可以更方便的返回多个计算结果(返回一个数组即可)。
我花了三天时间做完了重构的工作,可以在 https://github.com/cloudwu/math3d 看到现在的版本。Lua 库是我们主要使用的部分,但 mathid 这个子模块可以供 C/C++ 直接调用。