« March 2022 | Main | May 2022 »

April 19, 2022

蒙皮数据的压缩

传统的蒙皮数据需要在模型顶点上存两组数据,其一为该顶点受哪些骨头的影响,其二为受这些骨头影响的权重。因为 GPU 的对齐影响,通常游戏中会将同一顶点受影响的骨头数量上限设为 4 。如果不做任何优化,骨头总数在 256 以下时,每个顶点需要 4 个字节保存骨头编号,再用 4 个 float 表示分别的权重。

因为权重之和总是为 1 ,所以,只用 3 个 float 也是可以的(第四个权重通过简单的计算就可以得到)。

因为权重总是 0-1 之间的数字,所以 32bits float 的精度远超所需,我们也并不需要浮点数。所以用 16bits [0,65535) 甚至 8bits (0,255] 来表示 0-1 的权重也够了。

所以,蒙皮一般至少占用 64bit 的定点数据 (4+4 bytes) 。

如果想进一步压缩,就需要一些复杂的技巧了。这两天读了几篇关于动画蒙皮数据压缩的 paper ,挺受启发的。

首先是这一篇 Vertex-Blend Attribute Compression

它给我最大的启发是:其实,每个顶点所受骨头影响的组合是很有限的。这也非常符合自觉,因为,顶点总是受空间位置上附近一些骨头的影响。空间位置相近的顶点,所受骨头影响的组合几乎是相同的,不同的只是权重比。

我们完全可以预处理一张表,记录该模型所有顶点的骨头索引组合,然后,在顶点上只需要保存表的序号,这样就可以大大压缩索引组合的数据。按论文中的数据看,一个一万多顶点的模型,组合数只有一千左右。也就是用 10bits 左右就能完整保存骨头组合信息。

我们可以创建固定大小的表,如果模型使用的组合数量超过了表的固定尺寸,就可以把一些不重要的组合去掉,归到最接近的表项上即可。

对于权重信息,也有压缩的余地。因为,骨头是次序无关的。如果我们总是按次序排列骨头,让权重从小到大,那么就有可能进一步的压缩数据。

以四根骨头为例,因为只需要实际储存三根骨头的权重,扔掉权重最大的那个,剩下三个量的范围分别是 (0,1/2], (0,1/3] , (0, 1/4] 。如果权重一定是从小到大排列,这三个两实际上可以看成四面体上的一个点。对将所有可能的点都排列在数轴上一一对应起来,就可以在限定位数内实现最大的精度。

如果我们用 10bits 保存索引,22bits 保存三个权重分量,就可以用 32bits 保存完整的顶点蒙皮信息了。


Permutation Coding for Vertex-Blend Attribute Compression 这篇 paper 进一步扩展了上面的方法。

允许每个顶点上的骨头数量是不定的。如果使用 64bits 数据,最多可以支持到 13 根骨头。骨头数量越多,就牺牲越多的精度;反之,如果骨头只有一两根的话,就最大限度的提高精度。

作者在 github 上公开的代码 ,写的清晰易懂。

April 12, 2022

Lua binding 中正确的 callback

今天修了个 skynet 中的 bug :在 Lua 中重新设置 callback 函数会失效。

skynet 已经有 10 年的历史了,十年前,某些 lua 的惯用法我还不太熟悉。比如,如果一个 C 框架的接口设置了一个回调函数,如何将其 binding 到 Lua 函数上,我当初没有想到好的方法。

最为传统的方法是把 Lua callback function 放到 Lua 的注册表中。当 C 框架的 callback 发生时,在 C 版本的 callback 函数中去查找 Lua 注册表中的 callback function ,然后用 pcall 执行它。

几乎大部分 C 模块的 lua binding 都用这个方案来封装 callback 函数。但它有一个最大的问题:调用 lua callback function 时,Lua 的状态机 L 如何传递。

在处理 lua_State *L 时,大部分人把 L 看成是 Lua VM 对象,实则不然。L 其实是一个 Lua thread ,而非 VM 。如果你把 set callback 时的 L 记录在 C 结构中,甚至用一个全局变量记录下来,是绝对错误的用法。因为 Lua VM 中可以有任意个 Lua thread ,调用 set callback 可以发生在某个 coroutine 中,而它是 gc 对象,未必能存活到 callback 被触发的时候。

记录 lua VM 创建时的 L 稍微好一点,因为这个 L 指的是 Lua VM 的 mainthread ,至少没有生命期错乱的问题,它能和整个 VM 共存亡。但是,这个 mainthread 的当前状态未必和 callback 发生时的 Lua 上下文能对应上。

因为,C 代码触发 callback 时,C 调用栈的源头,未必就是 Lua 的 mainthread :很可能是在 Lua 的某个 thread/coroutine 中触发了 C 函数,进入了 C side ,然后 C 框架再触发的 callback 。此刻,mainthread 很可能出于某种挂起状态下。

举例来说:可能是在 lua mainthread 下 resume 了某个 coroutine ,这个 coroutine 进入了 C side ,然后触发了 lua callback function 。如果你拿出 Lua VM 的 mainthread 的 L 来用,它实际停留在 resume coroutine 处,Lua 栈状态处于挂起状态,直接使用它是非常不可靠的。至少,你必须用 lua_checkstack() 来保证 stack 的最小容量。

总而言之,lua_State *L 这个东西更像是 Lua 到 C 边界处的上下文,而非静态的对象,如果你跨越 Lua 和 C 的边界,不应该保留这个上下文。

如果有条件,不要在 C callback 中访问任何 lua VM 。尤其是 C framework 的 callback 若是跨越了系统线程,这就是必须的。最好的方法是把 C callback 的信息放到 C side 的队列中,然后提供一个 Lua function 可以拉取队列的数据以做处理。即:一切 Lua VM 都应在明确的时机、在明确的系统线程中运行。

但这个方案不适合 callback 有返回值的场合。

如果 C 的框架被设计成需要外部注入 callback 来获得某些用户定义的状态(注:我认为这样的框架属于不良设计)。那么应该考虑如何的方案来设计 Lua binding :

在实现 lua side 的 setcallback 接口时,应该生成一个 userdata ,包含一个用 lua_newthread() 构造的独立 thread 的 L 指针。并把 thread 对象绑定在该 userdata 的 uservalue 上。

把 lua 的 callback function 置入这个独立新的 thread 的 L 上。如有必要,还可以在 L 上置入 lua_pcall() 所需的 error 处理 handler 。

将这个 userdata 的 C 指针放到 C side ,用于 callback 调用。管理好这个 userdata 的生命期,保证它可以活到 C side 的 callback 触发之后。

当 C callback 发生时,利用 userdata 中保存的 L 和 Lua 交互。调用事先保存好的 function 切记使用 lua_pcall() 。因为 lua callback function 就在这个 L 的 stack 上,所以,它比从 Lua 注册表中读回 callback function 还要高效。

具体实际案例,可以参考 skynet 这次所作修改