源文档:传送门
概述
文章给出的是一种基于C/S框架的基础逻辑介绍,还有针对一些情况的比如:高延迟玩家的游玩体验,服务器逻辑帧率和客户端渲染帧率的处理,收发信息延迟对逻辑的影响等情况的一些解决方案示例。
文章的态度是:网络游戏没有绝对的稳定情况
方案也没有必选的方案,介绍的方案,是基于游戏设计目的和体验选择的一种权衡方案,此强彼弱,正常不过。
介绍
C/S定义
CS框架就是有一个Server跑逻辑,在世界模拟、游戏规则、玩家输入上具备最高权限。
Client和Server以每秒20-30个数据包高频率同步互相通信。
Client之间不通信。表现层面的内容是Client接受到数据后在本地处理的。
挑战
有限的网络带宽,会导致服务器没法每一次世界的变化都向所有玩家发送更新数据包。
对此,解决方案通常是按照恒定的频率,同步世界状态的快照广播给所有客户端。
由于存在数据传输的ping时间(服务器收到是回ping时间,所以到达时间是ping的一半),客户端时间永远落后于服务器。
而服务器处理的,也是落后于客户端实际状态的有延迟的数据包。
这对于服务器和不同客户端的通信会产生逻辑问题,延迟越高越严重。
而且还可能会有丢包。
起源引擎方案
服务器有诸如
数据压缩、延迟补偿等技术应对网络通信的问题 客户端则使用预测和插值来改善体验。
该架构中旁观者是没有延迟补偿的,所以看到的不完全等同于玩家看到的。
基础架构
Tick-base
服务器用tick来模拟游戏。每tick走15毫秒,接近66.6tps。
每个tick,服务器处理用户的命令,检查游戏逻辑并且更新对象状态。
最坏的情况下,也就是还用传统拨号上网那种,客户端每秒只能接受5-7kb的包,更高频率的服务器tick模拟下,必定丢包。 所以客户端也有一个rate的设置,告诉服务器它的带宽容量。
客户端也可以主动修改请求快照的速率,但服务器不会以超过模拟tick的数量发送数据包,也不会超过客户端请求的rate。
由于客户端也有上报数据包的频率限制,所以通常一个数据包内包含不止一个
响应性
用户输入和世界应用输入得到反馈之间的时间。 客户端发送命令、服务器收到命令,做出处理,发出响应,客户端收到服务器响应。这段时间就是ping,或者延迟。
客户端循环
在看之前,补充一个知识点:
什么是模拟时间? 客户端告诉服务器:我这一帧的操作,是持续了 simTime 这么久的移动。 服务器收到后,用同一个模拟时间,在服务器的权威世界里重放玩家移动,保证客户端和服务器两边的运动计算是基于同一个时间步长。
服务器收到后,用同一个模拟时间,在服务器的权威世界里重放玩家移动,保证客户端和服务器两边的运动计算是基于同一个时间步长。
- 获取当前时间,用作指令的发起时间记录
- 收集用户的输入
- 使用模拟时间打包用户刚刚的输入并且发送
- 从网络获取服务器发过来的任何数据包
- 根据数据包,修改客户端本地的物体以及其状态
- 获取当前时间,以知道执行结束的时间
- 根据结束时间减去开始时间,得到下一次模拟帧的模拟时间。
模拟时间为何?
对于这个流程,我一开始有个这样的疑惑:为什么下一帧的模拟时间,用的是上一帧计算的时间?
因为帧开始的时候,并不知道这一帧要用多久时间,如果服务器收到的模拟时间和实际的差异太大:
- 模拟时间推进太慢 → 玩家感觉移动黏滞、比真实慢
- 模拟时间推进太快 → 玩家感觉移动过快、位置跳变
所以Valve的做法是用上一帧的实际耗时,来预估这一帧的模拟时间。 本质是延迟一帧的反馈。这是一种自适应的方案。 这是在用过去推断未来。
用上一帧的真实耗时,当作这一帧要模拟的时长。 这是一种方案,并不是说这是这个问题的最优解,而是这是这个问题的一种解法。
如果你的帧率完全恒定,那么帧时间会是一个准确的衡量标准。反之,帧时间就会不准确,但这个问题目前尚无真正的解决办法(除非你能在运行下一帧循环迭代之前,精准地确定其运行时长……)
服务器循环
- 获取当前时间,用作模拟开始的时间
- 从网络读取用户的输入数据包
- 执行客户端的数据包,然后按tick时间执行世界的模拟
- 对世界状态,可见对象等信息进行打包然后发给客户端
- 采样时间作为结束时间
- 计算得到模拟时间
在这个模型中,非玩家对象完全在服务器上运行,而玩家对象则根据传入的数据包来控制自身的移动。当然,这并非完成该任务的唯一可行方式,但这种方式确实是合理的。
常规总循环
- 客户端创建并向服务器发送用户指令。
- 服务器执行该用户指令,并将所有对象的更新位置回传给客户端
- 最终,客户端根据所有这些对象渲染场景。
这一核心流程虽看似简单,但在实际场景中表现不佳——用户的网络连接可能会出现明显延迟。主要问题在于客户端本质上是被动的,其仅能完成采样移动输入、等待服务器反馈结果这类简单任务。
若客户端与服务器的连接存在500毫秒的延迟,那么客户端的任何操作都需要500毫秒才能被服务器确认,且结果才能在客户端被感知。这种往返延迟在局域网(LAN)中或许尚可接受,但在互联网环境下则无法满足需求。
所以,基于这种情况,我们需要预测。(跳转到下面)
数据结构示例
半条命引擎的客户端输入信息结构:
typedef struct usercmd_s
{
// Interpolation time on client - 客户端插值时间
short lerp_msec;
// Duration in ms of command - 指令持续时间(模拟时间)
byte msec;
// Command view angles. - 玩家看向哪
vec3_t viewangles;
// intended velocities - 三维移动
// Forward velocity.
float forwardmove;
// Sideways velocity.
float sidemove;
// Upward velocity.
float upmove;
// Attack buttons - 这是一个位域,按下针对每个按下的不同按键有不同的表示。
unsigned short buttons;
//
// Additional fields omitted...
//
} usercmd_t;
技术-增量压缩
服务器并不是每次都发送世界的快照,只是发送自上次发送依赖的变化部分,也就是增量快照。
在每个往来服务器和客户端的数据包,都会有确认序号。 通常完整的快照只在游戏刚开始的时候,以及客户端断连严重丢包的时候才发送,客户端可以主动请求完整快照。
技术-实体插值
这部分是客户端的处理。 客户端展示的实体的位置和动画根据最近收到的快照进行连续插值。
这里还涉及到一个向后延迟渲染,也就是收到数据包后,不是立即渲染当前时刻的世界,而是将渲染时间向后推迟一个固定的"插值周期,这样每次渲染,都有至少前两个数据包可提供插值。 这种技术可以在即使你中途有个包丢了,你也可以依据更前的一个包和当前包进行插值。
服务器有延迟补偿,所以玩家不需要预估延迟导致的提前量来射击。
技术-预测
简单介绍
实际C/S交互中,client还要等server的话,基本效果就不忍直视了,玩家的网络状况总是五花八门。这时候为了更好的客户端体验,我们需要预测。
预测,就是数据还没从服务器发回来的时候,先在本地模拟执行玩家的输入。
Client收集玩家的命令后,会本地也存一份,让预测算法根据这些命令去计算。 计算的依据,会根据服务器的最后一次确认信息作为起点。 也就是有一条确认信息表明,最后服务器处理了哪条命令,同时也有信息表示处理了这条最后的命令后,玩家的精确位置和其他状态信息。
举例 玩家50FPS,所以一帧20ms(1秒=1000毫秒) 我们得到了延迟是100ms 那距离上一次服务器返回的信息,客户端会存有五条用户指令。 预测系统就将这五条指令模拟,预测完整的情况下能得到和服务器一样的结果。
为了将这种预测的效果和服务器运行的效果的差异降到最低,Valve的方案是,C/S对于玩家移动的代码是共享同一套代码,放在pm_shared/,也就是player_movement_shared。
执行后的效果会和收到的服务器指令对比,如果匹配则延续,如果不匹配则回溯并根据数据重新模拟指令。
前提:
- 实体能被玩家控制
- 需要预测的逻辑在服务器和客户端是一样的。
预测仅对本地玩家的实体有影响,由于没有其他玩家即时的输入信息,所以也没办法预测其他玩家的未来。
逻辑举例
"from state" <- state after last user command acknowledged by the server;最后一个服务器知晓的用户状态
"command" <- first command after last user command acknowledged by server;最后一个服务器知晓的用户指令
while (true)
{
// 一直跑逻辑,得到预测结果"to state"
run "command" on "from state" to generate "to state";
if (this was the most up to date "command")
break;
"from state" = "to state";
"command" = next "command";
};
出来的to state就是预测结果,用于渲染。
可能存在的问题
由于客户端收到服务器确认前,一直有一个"未确认命令"的列表。 在服务器告知已经确认执行前,每次模拟都会执行这些命令,直到这些命令被服务器确认,然后从这个"未确认命令"中移除。
重复触发?
那对于音效,视觉效果,不就会出问题吗? 所以同样的命令被执行过了,生成过了音效,就需要额外做一次去重,来避免重复的音效。 对于服务器可能会发布的,比如在这个地方放爆炸特效,如果这个指令已经被执行了,那是要去重跳过。 预测命令还是得重复执行来确保逻辑准确。
状态数据
客户端的状态数据不属于服务器权威的更新数据。 这里指的是私有的客户端数据,也就是实际上分两种情况。
- 客户端非私有的状态信息,直接拿服务器数据预测演算。
- 客户端私有信息:比如照相机位置,这部分数据服务器不会同步过来,本地客户端需要以一种
滑动窗口保存时间线上的每一步预测中间态来确认位置。
射击预测
这一步是复用了移动预测框架的逻辑。
- C\S双端代码共享,确保预测正确
- 服务器同步武器状态
- 客户端预测开火
- 服务器最终判定
在客户端预测武器开火,很可能还会促使决策端同时预测武器切换、部署和拔枪/收枪动作。通过这种方式,玩家会感觉游戏对自己的移动和武器激活操作有着100%的响应速度。这对于缓解许多玩家在如今的联网动作类游戏体验中不得不忍受的延迟感,起到了很大的改善作用。
为什么非得服务器处理?
这是一种反作弊的考量。 对于P2P的玩法,又比如一些模拟系统,没有反作弊的需求,其实就可以这样做。也就是客户端直接处理所有的数据,并把结果的指令广播。 也就是服务器权威方案。
预测,插值,除了客户端中为了优化体验需要做到,服务器判断玩家是否瞄准也需要有相应的逻辑。
技术-延迟补偿
何为延迟补偿
服务器根据保存的历史记录,查看某个用户操作的精确世界快照。
服务器会保留近一秒所有玩家的历史位置信息,当执行用于的命令的时候,根据:
命令执行时间 = 当前服务器时间 - 数据包延迟 - 客户端视图插值
然后根据得到的命令执行时间,去获取那个时间,所有玩家的位置,随后执行命令检查命中。本质上就是保留不断刷新的历史,触发判定的时候根据数据回滚查历史去判断。
对于高速移动的情况,服务器不可避免的在tickrate和速度之间存在精度限制。
工作原理:
- 为玩家计算一个延迟
- 在服务器历史记录中查找:玩家发出命令前,发给玩家并被玩家接受的世界更新信息。
- 根据这个更新信息,将其他玩家的时间回溯到当前玩家用户命令生成时的状态。
- 这种回退必须同时考虑
连接延迟和客户端所使用的插值量。 - 然后执行用户指令
- 将所有被回溯的玩家放回正确的位置。
也就是,GM需要知道,这个玩家的指令,经过延迟后,其实际发生的时机,然后回滚历史去判断,如果玩家看到的世界时间是那个时间,那么有没有命中。 当然这一步也需要将一些额外的信息跟着一起回退,比如玩家存活、玩家动作等。
延迟补偿带来了让玩家在自己的客户端跑而不会出现明显延迟,但这也有一些悖论的情况出现。这依然是一种游戏设计决策,一种权衡。
延迟补偿的缺点
延迟补偿的目的,是为了保证开枪方的反馈,牺牲了双方视角的一致性。
举个例子:
- 高延迟玩家看到的是旧的世界,他看到低延迟玩家还站在外面,但实际上低延迟玩家已经躲到掩体里面了。
- 高延迟玩家开枪后,延迟补偿机制将低延迟玩家拉回去大平地
- 在低延迟玩家看来,就好像死在掩体里面。
个人认为: 可以在这部分添加补偿的上限,如果传过来的命令太晚了,那这应该让网络差的玩家吃亏(或者踢出局)。 但正如本文一直说的,这是一种
权衡,Valve决定让玩家专注于游戏世界的即时交互。
客户端对象的显示
如何确定其他玩家在本地客户端的渲染位置呢? 外推法和插值法。
外推法
用类似弹道的形式,根据玩家已知的最后位置、方向、速度作为起始计算过了多少时间,玩家就到达了哪里。
但这种情况有个问题:玩家的移动并非具有很强的"弹道"特性,不稳定,经常急剧转向移动。 可以调整外推推算的限制来优化效果,但治标不治本。
插值法
采用一种推迟一帧的方案去确保准确性。 比如当有个最后一个的有效位置,那么向前追溯一点时间,来获得起点和终点,然后进行插值。
如果丢包,可以选择继续推算,或者保持最后一个位置。
通常这些方案,都有采样频率和效果的均衡问题。
插值法的"抹平"的特性,可能会导致弹跳球的视觉问题。所以Valve有一种不一样的插值算法。
位置历史记录插值法
和插值法的差异在于,并不只是保留最近的两三次状态,而是保存一整条完整的历史记录。(只能减少失真,不能彻底消除)
每次收到服务器的信息同步,客户端就增加一条历史记录。 这里的历史记录,并不是单纯的
{时间,坐标,角度},比如如果弹跳球问题在触地和高空失去势能矢量转向的时候,采样点不够的话,就需要增加物理惯性矢量的记录,来确保渲染插值是对的。
实际客户端渲染的帧率是比逻辑帧要快的,所以渲染的时候:
- 先找到目标回溯时间
- 在历史表里面找到这个目标时间的前后两条记录
- 根据时间插值得到目标时间的位置。
注意,这种方法还需要考虑一个情况:目标是否被传送?
不然物体会被"平滑"地移动很远的距离。所以针对这种情况,还可以在更新操作中设置一个"不进行插值"、"清除位置历史记录"。
又或者位移过大就判断为传送。
