在你自己的屏幕上,手电正指着你瞄准的地方;但在别人的屏幕上,它却指着别处。Project Ghost 的这个状态持续了大约一年,而它能活这么久,是因为拥有这支手电的玩家自己永远看不出问题,错误画面只出现在别人的屏幕上。
现象:在持有者自己的客户端上看手电位置是对的,但主机把它错误地同步给了其他客户端。
触发点:直到加入光线碰撞检测后问题才暴露,照着可互动物体却毫无反应。
线索:客户端站着不动时,主机一移动,主机屏幕上那个客户端的光也跟着动。
根因:主机没有直接沿用客户端的位置,而是用自己的相机重新算了一遍光的位置。
修复:客户端先在本地应用输入,把算好的位置传给主机,主机不再重算。
代码提醒:公开发布的修复只有图片形式,且可见片段有两处缺陷,本文不把它当作可直接复制的代码。
游戏与基本架构
Project Ghost 是 Caldera Interactive 的多人合作冒险 Roguelite,最多四名玩家,仅凭手电探索并搜刮黑暗的闹鬼宅邸。光照在这里不是装饰,而是核心玩法,所以手电方向错了不是外观小毛病:它决定了玩家能看见什么、瞄准什么、与什么互动。
按团队的描述,联网模型是有意做简单的。玩家进入游戏时,会选出一名玩家当主机,主机持有状态权威,也就是拥有世界的状态;其他玩家称为客户端,通过主机接收世界状态更新。需要即时反馈的东西,比如移动和手电方向,先在本地应用,再传给状态权威。最后一句话就是整份设计约定,而这个故障正是对它的违反。
最终暴露出来的症状
开发大约一年后,团队注意到手电指向没有在各客户端之间正确同步。客户端看自己的手电位置是对的,但主机把它错误地同步给了其他客户端。直到团队实现光线碰撞检测,这个错位才变得明显:客户端明明看到自己把光照在可互动物体上,却什么都没发生,因为状态权威并没有把光放在同一个位置。
起初团队怀疑是小问题,也许是舍入误差,或者是通过网络传输的小数精度丢失。这个最初的猜测值得记下来,因为它是对同步错位最自然的第一猜测,而它是错的。真正的缺陷不是精度,而是来源:这个位置到底是按谁的相机算出来的。
关键线索
经过许多小时的排查,团队找到了能一锤定音的实验:客户端站着不动、也不动光,主机在四处走动时,主机屏幕上那个客户端的复制光也会跟着动。一个属于静止玩家的位置,不可能因为另一个玩家走动就合法地移动。唯一自洽的解释是,复制出来的光不知怎么把主机的自己的位置也算了进去。
根因
团队自己对故障的复述很精确,本文原样保留:客户端在本地更新光的位置,但位置发给主机后,主机不是只用客户端的位置,而是按自己的相机重新算了一遍光的位置。两名玩家的相机位置不同,重算自然得出不同位置。客户端自己永远看不到问题,因为在它自己的屏幕上,本地值是对的;只有主机屏幕,以及所有接收主机版本的其他客户端,才会看到错误的光。
团队也说了这个错误从哪里来:他们在把代码库从 PUN(Photon 上一代联网包)迁移到 Fusion 时写错了。他们还用自己的话补充了两个促成因素:当时 Photon Fusion 的文档不完善,以及新架构的支持社区还很小。本文把这些记为团队自己的说法,而不是对今天 Photon 文档的独立评判。
修复,用大白话讲
第 1 步:立刻把客户端的输入应用到客户端自己的游戏里,保证本地反馈正确。
第 2 步:把算好的位置作为成品传给主机,而不是当作再算一次的原材料。
第 3 步:确保主机不再重算这个位置,让它原样同步给所有其他客户端,不掺入主机的位置。
团队参考的资料:Fusion 文档,尤其是 Tanknarok 示例项目,赶在原型阶段结束前查到并用上。
认真读公开发布的代码片段
修复代码只以图片形式发布,没有文本版,所以本文不把它作为代码块转载。逐行读这张图,结构是这样的:声明了一个联网的 Vector2 变量 flashlight_direction;在 FixedUpdateNetwork 里,如果通过 GetInput 拿到了 NetworkInputData 输入,片段自己的注释说主机收到输入并把联网变量赋值以传给所有人,代码把输入里的 aimDirection 归一化后赋给 flashlight_direction;然后,如果该方向的平方长度不为零,片段自己的注释说每个客户端都会调用这里并把光指到正确方向,代码取到角色的光,并把它的 forward 向量朝着一个由两分量方向构成的新 Vector3 做插值,系数是 Time.deltaTime 乘以 100。
这份转录经过逐行核对,并在公开发布的图片里发现了两处缺陷,所以该片段绝不能原样复制。第一,瞄准那一行用一个叫 dir 的变量来构造 Vector3,但 dir 在可见片段里从未声明;作用域内唯一的方向变量是 flashlight_direction。照着实现的读者必须在自己的代码里自行决定由哪个值提供这个方向,本文不会悄悄改写片段来假装这个问题不存在。第二,保存归一化 aimDirection 的那行赋值语句在图片里没有结尾分号,按图上印的样子是编译不过的。取角色光的辅助函数在可见片段里也没有定义,所以这只是一段摘录,不是完整文件。另外,FixedUpdateNetwork 里用 Time.deltaTime 是否符合作者自己项目的时间步设置,片段没有说明,这里记为未核验,而不是缺陷。上一节的设计规则已经对照团队自己的文字核实过;代码图片本身没有被核实为可直接运行。
为什么它能藏一年
三个特点合谋把这个故障藏了起来。第一,在光线碰撞检测出现之前,偏差很细微,没有什么迫使任何人去比对精确位置。第二,主机离客户端越远,现象越明显,但超过一定距离后,客户端的光就出了主机屏幕上的相机视野,变得难以诊断:误差最大的情形,恰恰也是最难观察的情形。第三,客户端在自己的屏幕上完全看不到这个问题。一个对拥有该物体的玩家不可见、而在最难观察时才最明显的故障,完全可以长期存在,却始终没人有足够把握去追查。
团队因此改了哪些测试做法
团队总结出三条测试教训,而且三条都是关于让不可见变得可见。光照系统是他们将近一年前最先实现的东西,此后他们学到了很多,所以最初的实现带着错误的假设。由此他们让全队都对功能测试负责:测试时所有测试者都直播自己的游戏画面并互相观看,以便在测试中抓住细微问题。同时他们在测试模板里加了一步:在不同距离上对照状态权威(主机)与输入权威(客户端),而不只是在一个舒服的距离上看一眼。
同一时刻、同一物体,把主机视角和客户端视角对照着看,不要只信其中一边。
有意改变主机与客户端之间的距离,因为这类故障随距离放大。
只在物主屏幕上看起来对的功能,在接收方确认之前都算没测过。
把这些带回你自己的项目
对每个同步值都先问清:谁拥有输入,谁拥有状态,并在代码扩散之前把答案写下来。
对每一次传递都定清楚:接收方是转发成品值还是重新计算,绝不要让一次重算藏在转发里面。
有意去测远距离情形,而不只测演示房间里看起来没问题的近距离情形。
公开发布的片段如果只有图片,先转录再逐行核对,然后再让任何人复制,包括那些用了却从未声明的名字。
来源:Caldera Interactive,Our Game Was Broken For a Year,2023 年 5 月 1 日发布,calderainteractive.com/blog/our-game-was-broken-for-a-year。本文全部故障、根因、修复与测试说法均出自该文;逐行代码核读与两处缺陷是本文对该文代码图片的自查,已如实说明。本文没有编造任何新的基准、帧率、代码或测试结果。本文不含任何联盟链接,也不写价格。
一行被重算 63000 次的字符串长度,是怎么让 GTA Online 读盘 6 分钟的
一个缺失字形就要 16 MB:Foxy Dumplings 如何把字体图集内存从 48 MB 压到 4-8 MB
Unity 大型 RTS 一个月帧数翻 3 倍:先查 profiler,再谈重构

