Skip to content
云风 edited this page Jan 23, 2024 · 1 revision

数学库

Ant 使用 glm 进行向量运算。它在 SIMD 指令集上的优化做得很好,对于这种基础设施,没有必要又开发一套。但本文并不介绍 Ant 是怎样使用 glm 的,它仅是一些数学计算而已。这里重点介绍的是引擎在 Lua 层面如何管理 vector matrix 这样的向量对象。我们为 Ant 开发了一个名为 math3d 的数学库,本文将介绍它的来龙去脉。

math3d

Godot 的 FAQ 中,官方在解释为什么要开发自己的脚本语言 GDScript 而不是用 Lua 或 javascript 时,给出了 6 点技术理由。其中第 4 点是:

No native vector types (vector3,matrix4,etc.), resulting in highly reduced performance when using custom types (Lua, Python, Squirrel, JS, AS, etc.)

没错,在没有原生向量类型支持的动态语言中,自定义 vector matrix 这些数据类型都有极大的额外性能开销。对于 Lua ,自定义复杂类型意味着你需要用 table 或 userdata 来实现 vector 或 matrix 。如果我们用 C/C++ 编写成为性能瓶颈的 ECS system ,操纵 lua table 是不必要的。所以,一般的选择会是 userdata 。

但 userdata 依然很重,它难以放入 ECS 的 C 组件中。即使对于纯 Lua 代码来说,userdata 也有额外的 GC 成本以及多余的内存开销。要保障 Lua 构建的系统高效运行,GC 是绕不开的一环。

我们需要一个更轻量的向量对象,可以在 C/C++ 中直接访问、引用;又可以在 Lua 中方便的使用。math3d 很好的解决了这个问题。这篇 blog 介绍了它的开发理念。

mathid

在 math3d 中,所有的向量对象,vector4 matrix4 quaternion 以及这些向量的数组(可用来表达 aabb 等),都是用 64bit id 的形式表示的。所有的对象都是不变量,所以,当你将两个 matrix4 相乘时,会得到一个新的 id ,而无法修改乘数变量本身。

这种设计,不仅可以让 Lua 代码使用向量更高效,在 C/C++ 代码中,统一的 mathid 作为一个 int64 使用起来也更方便。相比传统引擎中的向量对象,开发者不必纠结于使用引用还是值,如何管理向量的生命期更好。

如果我们有无限内存,使用 64bit id 表示向量的不变量是非常简单的。但现实必须考虑这些 mathid 的生命期管理。

transient id

大部分场合,我们将引擎使用的向量视为临时的。这些 id 的生命期只活到当前渲染帧的末尾。这有点像 C 代码中的 stack 管理方式,当你调用一个函数时,可以在栈上构造非常多的临时对象,函数返回时,栈上的临时对象以 O(1) 的复杂度一次销毁。你可以在函数中调用更深层次的函数,把当前栈帧上的对象引用传下去,而无需做复杂的生命期管理。math3d 把这样的一个临时生命周期扩大到了整个游戏帧。每一个构造出来的向量对象,会有唯一的 64bit id ;在同一帧内,可以自由传递这些 id ,框架保证了它们的生命期一定存在于当前帧内。

在一些需要精细优化的场合,为了避免太多的临时对象挤兑内存,还可以设置 checkpoint 。它就像函数调用栈一样,设置一个回收点,以后可以返回这个回收点,把中间临时构建的对象快速回收。

有些场合,当前帧的 id 还需要将生命期延续到下一帧。这多见于 发送消息 的场合,当前 stage 发送的消息,可能会在另一个 stage 中处理。处理消息的 stage 可以在同一帧的后续,也可能在下一帧靠前的位置。

这些临时对象,如果超出了生命期,就无法取出内部的值。但因为使用 id 引用它,引擎可以对使用超出生命期的对象报错,而不会出现错误的行为。开发者应把这些错误的使用视为 bug ,有责任避免它。

constant id

有些向量是需要一个永久的生命期。例如 Prefab 中的向量,它在进程中的总量有限(取决于文件系统中的预制件数量),但生命期明显长于当前游戏帧。我们只需要构造持久 id 就可以引用这些向量对象。这类对象不需要单独的销毁接口。

marked id

光有持久对象是不够的。在某些场合,只增不减的管理策略可能吞噬完内存。比如,Scene 对象中的 SRT 属性就是需要有更长生命期的。它们不会每帧修改,甚至不会每帧使用(如果超出了摄像机镜头,渲染模块很可能不会触碰这些对象)。所以,它们会存活很久。

math id 都是不变量,我们不能修改一个 id 里面对应的向量值。当我们修改场景对象的向量属性时,必须使用一个新的 id 换掉旧的。

要精确控制一个 id 的生命期,我们可以用 mark 这个 api 将一个 transient id 转换为一个 marked id 。marked id 是不会在帧末销毁的,直到调用 unmark api 去掉内部引用。注:即使一个 marked id 被 unmark 了,它依然会活到当前帧的结束,变现的像一个 transient id 那样。

marked id 的管理是 Ant 引擎开发中最需要慎重对待的部分

控制 marked id 是一个精细活,一旦出错会导致内存泄露,或是生命期过期的 bug 。好在大多数 marked id 的管理工作都是引擎本身完成的,使用引擎的开发者不需要关心。如果必须要自己控制它们,建议做一些封装,不要直接调用 mark / unmark api 。

如果开发者需要制作一个接口中包含有 mathid 的模块,我们建议把接口都约定为 transient id 。把 mark/unmark 的细节留在模块实现内部。

lua 接口

在 Lua 中,mathid 是 lightuserdata 而不是 userdata ,这样就绕开了 Lua gc 。mathid 依附于 math3d 对象, math3d 是所有 id 的容器。进程中可以有多个 math3d 容器,它们之间无法通过 id 共享对象,引擎也无法识别一个 id 到底属于哪个容器(错误使用其它 math3d 容器中的 mathid 有极大概率在运行期被识别出来报错,但这不是 100% 确定的行为)。

如果需要在服务间传递向量对象,可以使用 math3d.serialize(id) 把它变成一个字符串。绝大多数可以接受 id 的接口都可以接受 string 类型,而不许额外转换。注:大多数接口还可以接受 lua table 表示向量,引擎会把 table 转换为 transient id 。这样的写法比使用 id 低效,但更为直观。

若需要在同一个 Lua 虚拟机内,使用消息传递向量,且这个消息并不会被及时处理,那么就需要对 id 做 mark 处理。建议做一些封装,减少开发者的心智负担。ant.math 包里提供了一些简单的封装方案,通过 lua 的 gc 机制自动化 mark / unmark 处理。

mathadapter

Ant 自身的 C 模块,都一致使用 mathid 交换向量对象。但引擎也使用了一些第三方模块,比如渲染底层的 bgfx ,骨骼动画的 ozz-animation 。这些第三方模块的接口通常使用 float * 来传递向量对象。为此,我们实现了 mathadapter 来负责桥接工作。得益于 Lua 的动态性,我们只需要把接口为 float * (对 Lua 来说是 lightuserdata)的 lua api 交给 mathadapter 封装一下,就会换成 mathid 的接口。

Clone this wiki locally