小飞机工作笔记(三)关于预测与校正的一点思考
项目虽然采用了帧同步+回滚的方案,但是实际上线后的表现却不如人意。目前表现在几个问题上:
1)不同客户端网络延时不一样,网速越好的客户端越容易出现回滚。频繁的回滚对性能造成了很大的影响。在chrome或者messenger中勉强还可以,但是在微信中由于执行引擎的落后,JS脚本成了性能的最大瓶颈,卡帧、掉帧非常严重;
2)对战中发现其它玩家的表现很“飘”,分析是因为本地演算预测与实际的操作指令差别太大导致的。其它玩家指令到来时本地已经向前演算了一段时间,当前客户端回滚后,渲染层再插值,渲染层中其它玩家的当前位置与逻辑层的当前值差别太大时(包括方向和速度大小)就会导致飞机忽快忽慢,让人觉得很“飘”。从目的上来讲,即使在相当大的延迟内也依然尽量让其它玩家的表现尽可能平滑,这个方案已经达到了它的目的。但是从效果上来看,则并不能令人满意;
3)本地在向前演算中可能会出现不该发生却发生的行为,比如玩家B没有发出子弹,但由于未能接收到B的熄火指令,导致本地演算产生了子弹。这种情况下子弹个体就必须予以撤消。而有时则又可能出现该发生却未及时发生的行为,比如玩家B打出了子弹,但是由于未能及时收到玩家B的开火指令,导致子弹未能及时出现在屏幕上。而接收到B的指令时,回滚后会重新演算至本地当前帧,会导致在第N帧产生的子弹,却在当前是在第M帧时需要渲染出来,向前又演算了M-N帧,渲染层中子弹会出现在离发射点很远的地方,令玩家莫名其妙。此时(回滚+ECS)在渲染层对逻辑层属性插值的缺点就暴露出来了。由于不确定何时会进行回滚,也不确定会回滚多少次,每个状态帧都是不可依赖的,难以依据它捕捉一个完整的行为的开始和结束。当然,真的要做也是可以做的,但是这个行为随时可能变动,必须对每个阶段的行为做充分的撤消/重建/插值,复杂度非常之高。相比之下,若去除回滚,不采用实时对逻辑层插值的做法,而是按照传统的方式来捕捉事件,由渲染层根据此事件自己确定子弹的出生点,并且从出生点出生,同时通过时间戳对子弹的速度插值,便可得到一个完整的加速追赶行为,而不会出现离开发生点很远的情形。不过这种方式也有它的问题。由于去除了回滚,逻辑层无法再超前于网络驱动帧演算,只能在渲染层做一些预测插值。
此外,去除回滚后,由于逻辑帧依赖于网络驱动帧,网络抖动的情况下,本地的AI也会随之抖动,无法再平滑地向前演算渲染。至于玩家,因为其没有AI,只是依赖于玩家的操作,因此是否回滚对其影响不大,但是对机器人AI的影响在网络抖动较大时会有比较大的差别。在手游中还可以在UDP上定制可靠传输协议来提升传输效果,但是浏览器长链接走的是TCP上的WebSocket协议,是没辙的了。
从以上分析来看,若想既兼顾性能,又能得到较好的AI预测体验,那么最好是能允许部分范围内的前向计算+回滚。除非是划分的模块状态相互之间完全独立,否则要对单个状态模块加以回滚又保持不同客户端的一致性,那只能在限制副作用的有限范围内去处理。
另外一种方法就是摒弃帧同步,采用状态同步,自然包括AI的状态也要同步下来。但是在前向演算时,依然可以在客户端编写AI(这时可以利用ECS模式复用服务器的AI代码),并且可以只依赖于部分状态进行演算。AI的表现并不要求是完全准确的,在状态依赖与演算的策略上也有空间可以发挥。客户端可以选择在服务器同步部分必要的状态后即可回滚/演算,而不同客户端并不要求在每一网络帧计算上都保持一致,只要在关键事件上由服务器同步一致即可。比如在一张大地图上,只对玩家当前所在的区域进行演算即可,服务器AOI同步过来时再做回滚校正。不过具体细节我也没有再思考了,有机会再进一步实践吧。而如果是手游项目而且附带相当AI(技能事实上也是AI之一种,一旦释放就要走完一个固定的流程),对实时要求也非常高的话,我还是会优先选择帧同步+回滚的方案的。