Unity中的客户端物理预测

图片

TL; DR


我创建了一个演示,演示如何在Unity- GitHub中实现玩家对玩家身体运动的客户端预测。

引言


2012年初,我写了一篇关于Unity玩家身体运动的客户端如何实现预测的文章。 感谢Physics.Simulate(),不再需要我描述的笨拙的解决方法。 旧帖子仍然是我博客上最受欢迎的帖子之一,但是对于现代Unity而言,此信息已不正确。 因此,我要发布2018版本。

客户端是什么?


在竞争性多人游戏中,应尽可能避免作弊。 通常,这意味着使用带有专制服务器的网络模型:客户端将输入的信息发送到服务器,然后服务器将该信息转换为玩家的动作,然后将玩家的状态快照发送给客户端。 在这种情况下,按键和显示结果之间会有延迟,这对于任何活动的游戏都是不可接受的。 在客户端进行预测是一种非常流行的技术,它可以隐藏延迟,预测最终产生的移动并将其立即显示给玩家。 当客户从服务器收到结果时,他会将其与客户预测的结果进行比较,如果结果不同,则预测是错误的,需要进行更正。

从服务器接收到的快照总是相对于客户端的预测状态来自过去(例如,如果将数据从客户端传输到服务器并返回需要150毫秒,则每个快照将至少延迟150毫秒)。 结果,当客户需要纠正错误的预测时,他必须回滚到过去的这一点,然后再现在间隙中输入的所有信息以返回到他所在的位置。 如果玩家在游戏中的移动基于物理原理,则需要Physics.Simulate()来在一帧中模拟多个循环。 如果在移动播放器时仅使用角色控制器(或胶囊铸件等),则可以不用Physics.Simulate()-并且我认为性能会更好。

我将使用Unity重新创建一个我很喜欢的网络演示,该演示是 Glenn Fiedler 的网络物理学之禅” 。 玩家拥有一个可以向其施加力量的物理立方体,将其推入场景。 该演示模拟了各种网络状况,包括延迟和丢包。

开始工作


首先要做的是关闭自动物理模拟。 尽管Physics.Simulate()允许我们告诉物理系统何时开始仿真,但是默认情况下,它会基于固定的项目时间增量自动执行仿真。 因此,我们将通过取消选中“ 自动模拟 ”框,在“ 编辑”->“项目设置”->“物理 ”中将其禁用。

首先,我们将创建一个简单的单用户实现。 输入被采样(w,a,s,d用于移动,空间用于跳跃),所有这些都归结为使用AddForce()施加到刚体的简单力。

public class Logic : MonoBehaviour { public GameObject player; private float timer; private void Start() { this.timer = 0.0f; } private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs; inputs.up = Input.GetKey(KeyCode.W); inputs.down = Input.GetKey(KeyCode.S); inputs.left = Input.GetKey(KeyCode.A); inputs.right = Input.GetKey(KeyCode.D); inputs.jump = Input.GetKey(KeyCode.Space); this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); } } } 


不使用网络时播放器移动

将输入发送到服务器


现在我们需要将输入发送到服务器,服务器还将执行此运动代码,制作多维数据集状态的快照,然后将其发送回客户端。

 // client private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.inputs = inputs; input_msg.tick_number = this.tick_number; this.SendToServer(input_msg); this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); ++this.tick_number; } } 

到目前为止,这里没有什么特别的,我唯一要注意的是添加tick_number变量。 需要这样,当服务器将多维数据集状态的快照发送回客户端时,我们可以找出对应于该状态的客户端的间歇方式,以便我们可以将该状态与预测的客户端进行比较(稍后将对其进行添加)。

 // server private void Update() { while (this.HasAvailableInputMessages()) { InputMessage input_msg = this.GetInputMessage(); Rigidbody rigidbody = player.GetComponent<Rigidbody>(); this.AddForcesToPlayer(rigidbody, input_msg.inputs); Physics.Simulate(Time.fixedDeltaTime); StateMessage state_msg; state_msg.position = rigidbody.position; state_msg.rotation = rigidbody.rotation; state_msg.velocity = rigidbody.velocity; state_msg.angular_velocity = rigidbody.angularVelocity; state_msg.tick_number = input_msg.tick_number + 1; this.SendToClient(state_msg); } } 

一切都很简单-服务器等待输入消息,当它收到消息时,它会模拟一个时钟周期。 然后,他拍摄多维数据集的结果状态快照,并将其发送回客户端。 您可能会注意到状态消息中的tick_number比输入消息中的tick_number大1。 这样做的原因是,从我个人的角度来看,我更方便地将“间歇状态100的玩家状态”视为“间歇状态100的玩家状态”。 因此,测量中的玩家100的状态与测量中的玩家100的输入相结合,为测量中的玩家101创建了新的状态。

状态n +输入n =状态n + 1


我并不是说您应该采用相同的方式,主要是方法的稳定性。

还必须说,我不是通过真实的套接字发送这些消息,而是通过将它们写入队列,模拟数据包延迟和丢失来模仿它们。 该场景包含两个物理多维数据集-一个用于客户端,另一个用于服务器。 更新客户端多维数据集时,我禁用了服务器多维数据集的GameObject,反之亦然。

但是,我不会以错误的顺序模拟网络反弹和数据包传递,这就是为什么我假设每个接收到的输入消息都比前一个消息新的原因。 为了非常简单地在一个Unity实例中执行“客户端”和“服务器”,需要进行此模仿,以便我们可以在一个场景中组合服务器和客户端多维数据集。

您还可以注意到,如果输入消息被丢弃并且没有到达服务器,则服务器模拟的时钟周期比客户机少,因此将创建不同的状态。 的确如此,但是即使我们模拟了这些省略,输入仍然可能是不正确的,这也将导致不同的状态。 我们稍后将处理此问题。

还应该补充一点,在此示例中,只有一个客户端,从而简化了工作。 如果我们有多个客户端,那么我们将需要a)调用Physics.Simulate()时,检查服务器上是否仅启用了一个玩家的多维数据集,或者b)如果服务器从多个多维数据集接收到输入,请一起模拟它们。


延迟75毫秒(往返150毫秒)
0%丢失包裹
黄色立方体-服务器播放器
蓝色立方体-客户端收到的最后一个快照

到目前为止,一切看起来都不错,但是我对视频中录制的内容有些挑剔,以隐藏一个相当严重的问题。

判定失败


现在来看一下:


真是的

录制该视频时不会丢失任何包,但是,在输入完全相同的情况下,模拟仍然会有所不同。 我不太了解为什么会发生这种情况-PhysX应该具有确定性,因此我发现令人惊讶的是,仿真经常会发生分歧。 这可能是由于我经常启用和禁用GameObject多维数据集,也就是说,使用两个不同的Unity实例时,问题可能会减少。 这可能是一个错误,如果您在GitHub的代码中看到它,请告诉我。

不管怎样,不正确的预测是客户端预测中必不可少的事实,因此让我们对其进行处理。

我可以倒带吗?


该过程非常简单-当客户预测移动时,他保存状态缓冲区(位置和旋转)和输入。 从服务器收到状态消息后,它将接收到的状态与缓冲区中的预测状态进行比较。 如果它们之间的差值太大,那么我们将重新定义客户端多维数据集的状态,然后再次模拟所有中间度量。

 // client private ClientState[] client_state_buffer = new ClientState[1024]; private Inputs[] client_input_buffer = new Inputs[1024]; private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.inputs = inputs; input_msg.tick_number = this.tick_number; this.SendToServer(input_msg); uint buffer_slot = this.tick_number % 1024; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = rigidbody.position; this.client_state_buffer[buffer_slot].rotation = rigidbody.rotation; this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); ++this.tick_number; } while (this.HasAvailableStateMessage()) { StateMessage state_msg = this.GetStateMessage(); uint buffer_slot = state_msg.tick_number % c_client_buffer_size; Vector3 position_error = state_msg.position - this.client_state_buffer[buffer_slot].position; if (position_error.sqrMagnitude > 0.0000001f) { // rewind & replay Rigidbody player_rigidbody = player.GetComponent<Rigidbody>(); player_rigidbody.position = state_msg.position; player_rigidbody.rotation = state_msg.rotation; player_rigidbody.velocity = state_msg.velocity; player_rigidbody.angularVelocity = state_msg.angular_velocity; uint rewind_tick_number = state_msg.tick_number; while (rewind_tick_number < this.tick_number) { buffer_slot = rewind_tick_number % c_client_buffer_size; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = player_rigidbody.position; this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation; this.AddForcesToPlayer(player_rigidbody, inputs); Physics.Simulate(Time.fixedDeltaTime); ++rewind_tick_number; } } } } 

缓冲的输入和状态数据存储在一个非常简单的循环缓冲区中,其中量度标识符用作索引。 我为物理时钟频率选择了64 Hz的值,也就是说,一个1024个元素的缓冲区为我们提供了16秒的空间,这远远超出了我们的需要。


校正已开始!

冗余输入传输


输入消息通常很小-按下的按钮可以组合成仅占用几个字节的位字段。 我们的消息中仍然有一个小节编号,占用4个字节,但是我们可以轻松地使用带有进位的8位值来压缩它们(也许0-255的间隔太小,我们可以放心地将其增加到9或10位)。 尽管如此,这些消息还是很小的,这意味着我们可以在每条消息中发送很多输入数据(以防先前的输入数据丢失)。 我们应该回到多远? 嗯,客户端知道他从服务器收到的最后一个状态消息的度量编号,因此再回溯此度量没有任何意义。 我们还需要对客户端发送的冗余输入数据量施加限制。 我没有在演示中进行此操作,但应在完成的代码中实现。

 while (this.HasAvailableStateMessage()) { StateMessage state_msg = this.GetStateMessage(); this.client_last_received_state_tick = state_msg.tick_number; 

这是一个简单的更改,客户端只需写入最后收到的状态消息的度量编号。

 Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.start_tick_number = this.client_last_received_state_tick; input_msg.inputs = new List<Inputs>(); for (uint tick = this.client_last_received_state_tick; tick <= this.tick_number; ++tick) { input_msg.inputs.Add(this.client_input_buffer[tick % 1024]); } this.SendToServer(input_msg); 

客户端发送的输入消息现在包含输入数据列表,而不仅仅是一项。 带有小节编号的部件将获得一个新值-现在,这是此列表中第一个输入的小节编号。

 while (this.HasAvailableInputMessages()) { InputMessage input_msg = this.GetInputMessage(); // message contains an array of inputs, calculate what tick the final one is uint max_tick = input_msg.start_tick_number + (uint)input_msg.inputs.Count - 1; // if that tick is greater than or equal to the current tick we're on, then it // has inputs which are new if (max_tick >= server_tick_number) { // there may be some inputs in the array that we've already had, // so figure out where to start uint start_i = server_tick_number > input_msg.start_tick_number ? (server_tick_number - input_msg.start_tick_number) : 0; // run through all relevant inputs, and step player forward Rigidbody rigidbody = player.GetComponent<Rigidbody>(); for (int i = (int)start_i; i < input_msg.inputs.Count; ++i) { this.AddForcesToPlayer(rigidbody, input_msg.inputs[i]); Physics.Simulate(Time.fixedDeltaTime); } server_tick_number = max_tick + 1; } } 

当服务器接收到输入消息时,它将知道该消息中第一个输入的度量编号和输入数据量。 因此,它可以计算消息中最后输入的度量。 如果此最后一个度量大于或等于服务器度量编号,则它知道消息包含至少一个服务器尚未看到的输入。 如果是这样,那么它将模拟所有新的输入数据。

您可能已经注意到, 如果我们限制输入消息中的冗余输入数据量,那么在丢失足够多的输入消息的情况下,服务器和客户端之间将存在模拟缺口。 也就是说,服务器可以模拟度量100,发送状态消息以开始度量101,然后接收以度量105开始的输入消息。在上面的代码中,服务器将转到105,它将不会尝试基于最新的已知输入数据来模拟中间度量。 是否需要它取决于您的决定以及游戏的内容。 就个人而言,由于网络状况不佳,我不会强迫服务器推测并在地图上移动玩家。 我认为最好将播放器留在原处,直到连接恢复。

在Zend of Networked Physics演示中,客户端具有发送“重要动作”的功能,也就是说,客户端仅在与先前传输的输入不同时才发送冗余输入数据。 这可以称为输入增量压缩,并可以进一步减小输入消息的大小。 但是到目前为止,我还没有做到这一点,因为在此演示中,没有优化网络负载。


在发送冗余输入数据之前:当25%的数据包丢失时,多维数据集的移动速度缓慢且抽搐,它会继续被抛回去。


发送冗余输入数据后:丢失25%的数据包后,仍会进行抽动校正,但多维数据集以可接受的速度移动。

可变快照频率


在此演示中,服务器向客户端发送快照的频率有所不同。 降低频率后,客户端将需要更多时间从服务器接收更正。 因此,如果客户在预测中误入歧途,则在收到状态消息之前,他可能会偏离得更多,这将导致更明显的更正。 由于快照的频率很高,因此丢包的重要性不那么重要,因此客户端不必等待很长时间即可接收下一个快照。


快照频率64 Hz


快照频率16 Hz


快照频率2 Hz

显然,快照的频率越高,快照越好,因此应尽可能频繁地发送快照。 但这还取决于附加流量的数量,其成本,专用服务器的可用性,服务器的计算成本等。

平滑校正


我们创建不正确的预测,并且比我们希望的更频繁地得到抖动校正。 如果无法正确访问Unity / PhysX集成,我几乎无法调试这些错误的预测。 我之前已经说过,但是我再重复一遍-如果您发现与物理学有关的某些东西,而我错了,那么请告诉我。

我通过使用良好的旧平滑度对裂缝进行上光来规避了该问题的解决方案! 进行校正时,客户只需将播放器在正确状态方向上的位置和旋转平滑几帧即可。 物理立方体本身会立即得到纠正(它是不可见的),但是我们还有第二个立方体仅用于显示,从而可以进行平滑处理。

 Vector3 position_error = state_msg.position - predicted_state.position; float rotation_error = 1.f - Quaternion.Dot(state_msg.rotation, predicted_state.rotation); if (position_error.sqrMagnitude > 0.0000001f || rotation_error > 0.00001f) { Rigidbody player_rigidbody = player.GetComponent<Rigidbody>(); // capture the current predicted pos for smoothing Vector3 prev_pos = player_rigidbody.position + this.client_pos_error; Quaternion prev_rot = player_rigidbody.rotation * this.client_rot_error; // rewind & replay player_rigidbody.position = state_msg.position; player_rigidbody.rotation = state_msg.rotation; player_rigidbody.velocity = state_msg.velocity; player_rigidbody.angularVelocity = state_msg.angular_velocity; uint rewind_tick_number = state_msg.tick_number; while (rewind_tick_number < this.tick_number) { buffer_slot = rewind_tick_number % c_client_buffer_size; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = player_rigidbody.position; this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation; this.AddForcesToPlayer(player_rigidbody, inputs); Physics.Simulate(Time.fixedDeltaTime); ++rewind_tick_number; } // if more than 2ms apart, just snap if ((prev_pos - player_rigidbody.position).sqrMagnitude >= 4.0f) { this.client_pos_error = Vector3.zero; this.client_rot_error = Quaternion.identity; } else { this.client_pos_error = prev_pos - player_rigidbody.position; this.client_rot_error = Quaternion.Inverse(player_rigidbody.rotation) * prev_rot; } } 

当发生错误的预测时,客户会在更正后跟踪位置/旋转差异。 如果位置校正的总距离超过2米,则多维数据集仅会突然移动-平滑效果仍然看起来很差,因此至少应使其尽快返回到正确的状态。

 this.client_pos_error *= 0.9f; this.client_rot_error = Quaternion.Slerp(this.client_rot_error, Quaternion.identity, 0.1f); this.smoothed_client_player.transform.position = player_rigidbody.position + this.client_pos_error; this.smoothed_client_player.transform.rotation = player_rigidbody.rotation * this.client_rot_error; 

在每个帧中,客户向正确的位置/旋转方向执行lerp / slerp动作10%,这是平均运动的标准幂律法。 它取决于帧速率,但是对于我们的演示而言,这已经足够了。


延迟250毫秒
丢失10%的包裹
如果不进行平滑处理,则校正非常明显


延迟250毫秒
丢失10%的包裹
通过平滑处理,很难注意到校正。

最终结果效果很好,我想创建一个真正发送数据包的版本,而不是模仿它们。 但这至少是对于在Unity中具有实际物理对象的客户端预测系统的概念证明,而无需物理插件等。

Source: https://habr.com/ru/post/zh-CN432190/


All Articles