我的玩具引擎计划以Lua语言为第一业务语言。
在引擎可以加载出一个场景之后,我就需要一个相机控制器,来接收用户输入来移动和旋转相机,以实现场景漫游。
我打算使用Lua来编写这一逻辑。在计算相机的Transform时,需要进行一定的数学运算。这就需要一个Lua版的数学库。
怎么给Lua写一个简洁高效的数学库,这并不是最近才开始思考的问题。
早些年在使用tolua框架时,就发现在Lua中进行数学计算时会产生大量的临时对象,极大的加重GC的负担。
虽然这两年时不时就会想起这个问题,也一直没有解决方案。
这次,在没有了Unity的包袱之后, 希望能找到一条全新的思路。
为什么在C#中写数学运算就不会产生GC呢。根本原因是,在C#中(vector3,quaternion,matrix)等对象都是struct类型,即值类型。这些对象都是在栈上分配,函数返回即销毁。就算当值返回,也是直接值拷贝出去的。
在Lua中,严格意义的值类型只有boolean,number两种类型。虽然string表现的像个值类型,但是临时string对象一样会产生内存垃圾。
所以我们一般实现vector3时,会使用Table或userdata来保存xyz。这是因为Lua中的值类型不足以装下xyz这么多数据。
一个很直觉的思路,我们能不能扩展Lua中的值类型,使他最多能包含xyzw四个字段。
答案是能,但是代价很大。内存的代价,性能的代价,以及维护的代价。
我仔细回忆了这几年有限的客户端经历,我发现数学运算都是扎堆的。
换句话说,我们的数学运算一般都是几个有限的输入和几处有限的输出。但是中间计算过程很复杂,只要解决了这些中间过程产生的临时变量,那也算基本符合预期了。
沿着这个思路,即然Lua中只有numbert和boolean是值类型,那我有没有可能用number来代表一个vector3或quaternion呢?
答案是肯定的。我们只需要用C实现一片额外的空间,然后用索引指向这个vector3或quaternion的值就大功告成了。
基于以上思路,我实现了一个数学栈。这个栈的范围只能在一个函数内使用。
如果你想将计算结果返回到另一个函数使用,你只能将栈中的值取出,然后显式返回给其他函数。
如果其他函数需要再次进行数学计算,就需要重新开辟一个数学栈空间。
大致用法如下:
local mathx = require "engine.math"
local camera_up = {x = 0, y = 0, z = 0}
local camera_forward = {x = 0, y = 0, z = 0}
local camera_right = {x = 0, y = 0, z = 0}
local stk<close> = mathx.begin()
print("rotation", component.get_quaternion(self))
local rot = stk:quaternion(component.get_quaternion(self))
local up = stk:mul(rot, stk:vector3f_up())
local forward = stk:mul(rot, stk:vector3f_forward())
local right = stk:mul(rot, stk:vector3f_right())
stk:save(up, camera_up)
stk:save(right, camera_right)
stk:save(forward, camera_forward)
首先使用math.begin()来创建一个数学栈,接着我们就可以在栈上进行各种数学计算。
当数学计算结束时,我们可以使用stk:save来取出数学栈中的xyzw的值。
stk:save有两种使用方式,当我们传入一个table时,stk会直接将xyzw的值置入table内。我们还可以不传入参数,这时stk:save就会根据值的类型返回xyz或xyzw的值。
这里使用了Lua的toclose特性, 当栈使用完之后,__close函数会自动将栈对象放入Cache中。
下次调用math.begin时,直接从Cache中分配,这样可以做到0内存分配。
在实现完这个库之后,我特意与xlua做了一个性能对比。
local stk<close> = math.begin()
local v1 = stk:vector3f(xx_v3_1)
local v2 = stk:vector3f(xx_v3_2)
v2 = stk:vector3f_cross(v1, v2)
v2 = stk:vector3f_cross(v1, v2)
v2 = stk:vector3f_cross(v1, v2)
v2 = stk:vector3f_cross(v1, v2)
stk:save(v2, xx_v3_2)
与同样逻辑的xlua写法对比,性能要高出300%左右。并且随着计算过程的增加,性能优势会越来越明显。
除此之外,在进行数学计算之前,我们往往需要获取到transform中的position,rotation,scale等属性。
这些属性要么是vector3, 要么是quaternion。如果我们用table或userdata来返回依然会加重GC负担。
仔细数一下其实这些数据类型最大也只有4个变量。因此,我让函数component.get_position直接返回xyz三个值,而component.get_rotation直接返回xyzw四个值。
至于是否需要存到table里,这个交由业务逻辑来控制。
lua 写数学库确实会产生大量临时对象,优化的思路也只能是尽可能减少。
您这种写法效率确实提升了,但会让团队合作编码产生一定的上手困难。而且出错之后排查也有一定难度。
其实写法本质上没有太大变化,惟一的变化就是在一系列计算之后,多了save操作。一般情况来讲,在写中间逻辑过程中,没有增加半点心智负担。代码可读性也没有降低。即使是使用tolua/xlua生成的API,和C#原生的操作也还是有区别的。不过调试方面,你说的有道理,可能要我需要提供一个调试插件接口。