帧同步:浮点精度测试
本文首发于知乎专栏:MACK的游戏开发笔记,欢迎各位关注。
最早接触帧同步的概念是在2005年开发PS游戏高智能方程式赛车3的时候,当时负责赛车游戏的逻辑,物理,AI和录像回放等功能,录像回放就是使用点帧同步实现的。一场比赛只要保存玩家在不同时刻的操作即可,回放的时候就再跑一遍游戏在相同的时间执行相同的操作,得到相同的结果。对于主机游戏来说帧同步实现要简单很多,因为设备唯一,不存在浮点精度的问题,甚至都不需要逻辑层和表现层分离,只要简单的限帧即可。
2015年开发一款3D战斗卡牌游戏的时候又使用了帧同步技术,因为当时服务器只有一个人战斗逻辑在客户端,一开始使用主机模式问题很多,迫不得已临时改成帧同步。因为是中途改的所以实现比较简单,使用FixedUpdate对逻辑限帧,使用TCP,可以说问题还是很多的。但因为1V1的横版游戏,玩家操作频率非常低,战斗也相对简单,有些不同步和延迟也还能接受。当时因为时间紧迫,开发的时候就遇到一个不同步问题解决一个,例如脚本更新顺序,逻辑帧更新等,其实也发现有浮点精度的问题,但因为时间有限只是对一些地方做了截断保护,后续项目又改成状态同步了也没有深究。
后来也了解过同类型游戏刀塔传奇的帧同步,刀塔传奇逻辑使用lua脚本,双精度浮点数double的精度问题比float好很多,另外2D游戏的运算相对3D简单,当时只遇到过一个数学运算有问题做了特殊处理就解决了。
最近在做一款射击类MOBA项目的时候,又采用了帧同步技术(帧同步和状态同步的对比和选择后续做更详细的介绍)。这是一款3D物理的俯视角射击MOBA项目,游戏中大量运用了真3D物理运算,双摇杆射击,最多支持16人战斗。游戏节奏非常快,操作频率非常高,一直会有大量玩家同屏团战,每个角色射速极高每帧有多发子弹,每个子弹都有弹道和物理计算,是一款对性能和延迟要求非常高的游戏。这时候同步就不能有一点问题了,任何一个Error或者运算的不同步就会导致结果的巨大差异,并且非常显而易见,使游戏无法进行,因此浮点精度问题也必须得到彻底解决了。
浮点精度的问题在PC上就有跨平台的问题,因为语言编译器不同使用的寄存器和运算过程中的舍入方式不同。就会导致在不同平台下相同的float运算会出现不同的结果,这方面有很多文章阐述就不多做阐述。而在手机上IOS还好比较单纯,Android设备系统硬件千差万别就更容易出现精度问题了。
首先我做了一些单元测试。
*如上图,通过简单的单元测试函数对各种常规的数学运算做了测试。
测试方法是:
- 首先在Windows64位电脑上生成浮点型测试数据,然后通过测试函数执行n次运算。
- 将测试结果的二进制保存到配置文件中。这里需要说明的是因为float是小数点后7位,在编辑中最多可以显示到9位例如1.876777650比较起来不直观。另外也存在多个十进制数据对应一个二进制浮点数的情况,例如在编辑器中看到5.0,保存之后再读取显示可能就是4.9999999,他们两个对应相同的二进制是一样的。为了便于对比,我写了一个简单的函数将各种类型统一转成二进制,保存的时候按二进制写入和读取,输出的时候也对应输出浮点数的二进制表达方式。
*如上图,将各种类型转换成二进制字符串的函数。
- 在其他各种设备运行测试程序,读取浮点测试数据,执行相同的运算,显示测试数据和运算结果的十进制和二进制结果。
- 读取Windows64位电脑上的二进制测试结果,和当前运算结果进行比较,如果不同显示Error,并显示双方的二进制结果。
结果如下:
可以发现连简单的x*x+y*y+z*z计算就出现了结果不一致,并且x*x,y*y,z*z和x*x+y*y都是一致的,就是y*y+z*z出现了不一致,猜测可能是不同设备的寄存器使用和舍入方式不同导致的误差,任何一个数学运算都有可能出现。
进一步测试:
- 进过更细致的测试,我发现Windows,IOS,Android三个平台之间会出现float运算结果不一致的情况,其中Windows设备之间、IOS设备之间是一致的(也可能设备较少),Android平台下即使同一个平台不同机型之间也会有大量运算不一致。并且各种运算都有可能出现,没有规律可言(例如某个只有数学运算不一致)。
- 此外我又针对Unity的Physx物理引擎做了精度测试,创建一个3D世界,随机分布各种几何体胶囊,球,茶壶,立方体等等,然后从原点位置随机发射1000根射线,再用相同的方式记录和对比结果,最终发现物理运算也出现了不一致的现象!
- 最后我还让做AI的同事对Unity的Navmesh做了测试,在一张带Navmesh的场景上生成10000个测试路径对比。这次结果就比较有趣了,结果居然是一致的!
测试结果:
- 通过测试可以发现在如果逻辑代码中使用float连最基本的数学运算都保证不了,可见逻辑部分是不可能使用float了。
- 同样物理运算也会有不一致的问题,不能直接使用,但测试结果主要在浮点数位数的误差上。
- 使用Navmesh没有发现不一致的问题,可能有隐患但应该还是可以使用的。
解决方案:
- 代码中使用float的问题,我们通过在逻辑层使用定点数取代float来解决。我在刚入行的时候使用过定点数,当时主要是为了解决在手机上浮点数运算非常慢的问题。早期Doom,Quick等都使用过定点数的优化方式,在3D游戏编程大师的书中还提供过一套定点数学库。我的一个同事在此基础上实现了一套基于定点数的数学库,提供和Unity相似的接口,并对一些函数做了查表优化。另一个同事修改了导表工具,使得策划的填表数据也支持定点数,完美解决。后来我们还逆向了另外一款爆款帧同步游戏看了一下,他们也做了特殊处理,使用乘1000的方式来解决,类似我们的顶点数学库但更难使用。
- 因为大量使用了真实的3D物理,所以物理对我们来说是一道跨不过去的槛。我们尝试过多种解决方案,先是让公司引擎组的同事帮忙修改Unity和PhysX源码发现工作量巨大,然后又尝试修改bullet为定点数替换PhysX,但是修改后的Unity版本性能差了几百倍无法使用。最终迫于时间压力,我们还是采用了有些隐患的做法:只使用Unity最基本的射线和BoxCollider,在此基础上实现了一套简单的定点数物理引擎。但是即使这样还是会出现不一致的问题,我们又加入了尾数截断,按碰撞方向截断等方式保障一致性。最终在几十万把上线测试中还没有发现物理导致的不一致问题。(需要说明的是,如果要使用动态碰撞器,需要修改一下Unity源码,让物理引擎的Update可以再逻辑帧驱动,非常感谢引擎组同事的帮忙。Unity的新版好像提供了这个功能。)
- Navmesh没有发现问题,我们实际使用也没出现问题,我们只使用了Navmesh的路点。
总结:
Float的一致性问题是帧同步方案的最基本的问题,也是相对比较好解决的问题,在后续开发过程中我们还遇到了无数帧同步的坑和难点,特别是同步和延迟的问题。帧同步是一个上手简单快速的技术,但是对操作频繁对延迟要求极高的大型游戏来说就变的非常难了。
针对同步问题我们还开发了一整套工具,当出现不同步的时第一时间就会上报,同时上传录像和日志到文件服务器,可以较快速的定位问题,统计不同步率。配合敏捷开发每日自动构建,代码Review,每次提交自动编译等,及时发现问题。最近一次上线测试玩家进行了39727次战斗未出现不同步,同步率至少达到王者荣耀的水平。
帧同步的不同步的测试还是比较困难和痛苦的,因为只要有一点没测到导致不同步上线之后可能就有非常大的风险,后续也会开发更强大的自动化测试工具,实现一种愉悦的开发方式!
补充:
- 针对double类型我也做了测试,不一致性较float大幅下降,但还是会产生不一致问题。
- PhysX有一个一致性开关,即使打开了,也还是会产生不一致问题。
- 在延迟上也做了大量优化,逻辑层和表现层完全分离,不使用FixedUpdate的方式降低输出延迟;同时使用了TCP,自己实现的可靠UDP和冗余包的非可靠UDP三种方式通信;极致压缩优化协议包降低在MTU范围以内;逻辑帧负载均衡;分地域部署和匹配;多线程收发等等。延迟数据接近王者荣耀的水平(双摇杆操作更多,服务器部署上延迟略高)。