数学运算模块的改进
我为我们正在研发的 3d 引擎设计了一个非常规的数学运算模块。
它和很多为 Lua 封装的数学运算模块不同,并没有用 userdata 或 table 来实现矩阵向量对象,而是采用了 64bit 的整数 id 。其生命期管理是模拟了一个堆栈,我称之为数学栈,和程序运行时存放临时变量的堆栈以示区分。数学栈上存在过的矩阵向量对象的生命期都会维持一个固定周期,通常是一个渲染帧。每帧主动调用重置指令刷新。这个设计减少了在 lua 层面使用矩阵向量对象的心智负担。你不必担心运算产生的临时结果会增加过多 gc 的负担。构造新的矩阵向量对象的成本也非常的小。
在计算方面,我们将运算指令也压入数学栈,而不是单独设计计算函数。例如 A * B
的操作,写作 B A "*"
,字符串 "*"
作为乘法操作指令压栈。如果是 A * B * C
则写作 C B A "**"
,两次乘法可以拼接在一个字符串中。如果你还想对 A * B * C
的结果取逆 (Inversed),就可以写 C B A "**i"
,i 表示取逆的操作。
这么设计纯粹是性能上的需求。因为 Lua 调用 C 函数的开销在做小操作时无法完全忽略。为了减少密集运算中,多次调用 C 函数的调用开销,这种逆波兰表示法可以把多次计算操作合并到单次函数调用中。
当然这么设计会增加使用者的学习成本。我也想过做一些改进 但到目前为止还没有多余的精力去实施。好在克服初期的使用不适后,用起来也能接受。
之前一段时间我的工作重心在其它方面,数学运算库是同事使用和维护较多。在使用过程中持续为其增加所需的功能。最大的改变是去掉了最早我实现的一个简陋的数学运算模块,而换成了更加专业的 glm 。对外框架改动不多。
最近,我自己使用了几周后,发现了一些小问题,所以又持续做了一些改进。
首先是需要的运算操作越来越多,只用一个字符表示一种操作,固然性能上很好,但对使用者有很大的记忆负担。一开始我们想用驼峰命名法去命名新的操作指令,但之后换了另一个方法:
我设计了一种新的运算子,直接用 lua 函数表示,然后把这个运算子压入数学栈。这样 B A "*" 就可以写作 B A mul ,其中 mul 是一个 Lua 函数。但是,如果直接这么实现,就有回到了原点,我们想减少的函数调用又回来了。只不过从 mul(A , B) 转移到了数学栈内部处理时。
但我在实现时使用了一点技巧。我们约定,运算子只可以是一个 light C function ,即不含 upvalue 的用 C 实现的函数。它只能有一个参数,数学栈对象。符合这个约定的函数,我称之为 FASTMATH 运算子。
在模块内部,会绕过 lua_call
,直接对运算子进行调用,并不会产生新的 Lua 栈帧。这样,运算子的运算的额外成本就被减少到几乎为零,从一次 Lua 到 C 的函数调用减少为一次普通的 C 函数调用。
这里运用的一点小技巧是,这个运算子真的可以在 Lua 层面作为普通函数调用。这个特性可以方便调试。
为了实现这个特性,我们的 FASTMATH 函数其实是定义成这样的:
int func (lua_State *L, struct lastack *LS, struct ref_stack *RS);
因为 C 调用规则是调用者负责参数退栈,这个函数原型可以被视为和 lua 约定的 C 函数相同:
int func (lua_State *L);
在我们内部直接调用 FASTMATH 函数时,我们直接把数学栈需要的额外对象传递过去;而如果当作普通 lua 函数调用,则从 L 中获取(性能略低)。我们用 L 是否为 NULL 来区分两种调用方式,正常的 Lua 调用 L 一定不为 NULL ;而我们在内部快速调用时,故意把第一个参数传 NULL ,表示后两个参数是有效的。
第 2 个改进关乎模块间的交互。
我们的上层框架是用 Lua 实现,但底层许多内聚性很高,性能相关的模块是用 C/C++ 实现成一个可供 Lua 调用的模块。例如渲染 API 是对 bgfx 的封装;物理系统是对 Bullet 的封装;动画模块是对 Ozz 的封装。
但是,这样的模块一定需要输入或输出矩阵向量对象。这是模块间交互必然面临的问题:如何在模块间交换矩阵向量对象?
常规的方法是让物理/动画模块都按数学模块的标准使用相同的矩阵向量对象类型。但我认为这样就增加了模块间的依赖性,增加了系统间的耦合度。我认为解耦是更高的设计原则,用 C/C++ 实现高类聚的 Lua 模块,把魔鬼(脏活)装进瓶子里,这样才可以跟放心的用 Lua 做设计。所以在一开始,我就决定让这些需要和外部交换矩阵向量对象的接口全部把向量表达为 lightuserdata ,也就是普通的 C 指针。
以渲染模块为例,如果它需要输入一个矩阵,那么接口上就要求输入一个 lightuserdata ,从 C 层面看就是一个 float * ,指向 16 个浮点数;如果需要输入一个 vector4 , 也是一个 float * ,指向 4 个浮点数。渲染模块完全不关心使用者是用怎样的数学运算模块产生出这样的指针的。
我们同时也为数学运算库设计了一个运算子,可以把数学栈上的矩阵向量对象以指针的形式弹出。在运算过程中,一律使用 64bit id 来表示一个矩阵向量对象,在需要和渲染 API 交互时则转换为 lightuserdata 。
但在实现使用中,我发现这给使用者造成了很大的困扰。你需要关心什么时用 id ,什么时候用指针。而且转换为指针后,就丢失了原本 id 承载的类型信息,你不再能知道这个对象到底是矩阵,还是向量,或是一个四元数?一旦从 id 转换为指针后,也就无法再转回原本的 id 。
指针作为模块间的数据交换类型固然解耦了不同的 Lua 模块,但失去了类型检查,也增加了出错的机率。
解决这个问题的方法是增加一个中间层,把不同的模块桥接起来。最容易想到的方法是用 table 来做这个桥接,避免直接使用无类型的指针。但 table 的额外开销会使得之前在减少运算开销上所作的努力都打了水漂。我们设计这么一个非常规的数学运算模块,全部都是因为常规的(用 table 来表达矩阵向量对象)方法开销不可接受。
最终我还是选择了用一点点奇技淫巧来把桥接中间层的代价减到最小。
我们只针对 light C function 来做桥接。这些 C/C++ 开发的模块都符合这一点。这个桥接模块是数学库的一部分,它并不需要了解具体需要和怎样的库做桥接,而只用知道我们要转换怎样的函数:这个需要桥接的函数的哪些参数需要接收矩阵或向量,桥接层生成一个代理函数,把数学库的 id 转换为对应的指针,再去调用它。因为最终的函数是 light C function ,我们就可以直接调用,而不需要通过 lua_call
产生额外的 lua 栈帧。
然后,需要接收矩阵向量对象的库自己再调用桥接库,为自身产生代理函数,覆盖原本的版本。以 bgfx 的封装库为例,就需要替换掉 set_uniform
,set_transform
等等。这样,bgfx 的封装库就不需要依赖特定的数学库实现就可以使用。我大概花了一周时间,成功把独立维护的 bgfx lua binding 中 examples 用到的数学库,从之前临时开发的一个简单数学库迁移到目前引擎开发中维护的数学库上。如果你愿意,也可以把它桥接到别的你需要的数学库实现,这真正做到了 bgfx 的 lua binding 和数学库解耦。
下一个问题是,像动画库这样的模块,除了输入参数中要矩阵向量对象外,它还需要输出一些矩阵。之前我们在实现的时候,需要输出时就采用 userdata 来解决矩阵的生命期管理问题。动画库的 lua binding 为构建一个临时的 userdata 用于输出计算结果。使用者再将这个结果压入数学栈,转换为 id 。
这次我们的桥接层也要解决这类问题。
这次采取的方案是,模块统一采用传入指针来接收输出。即,由调用者分配好接收输出数据的内存,如果想接收一个矩阵,就分配 16 个浮点数的空间,把地址作为参数传入。
桥接层会统一对输出做一个转换,使用者不需要再传入指针接收输出。再代理函数中,会在堆栈上预留一块空间,调用完真正的函数后,把堆栈上接收到的矩阵向量对象直接压入数学栈。
如果你对我们实现的数学库有兴趣,可以看看我从还在开发的 3d 引擎项目中摘取出来的仓库。使用的范例可以在 bgfx 的 lua binding 中找到。需要注意的是,bgfx 的 examples 是从 C++ 版本逐行翻译到 lua 的,它并不是对数学库的最佳使用形式。如果从头实现功能的话,这个数学库会有更好的使用模式。