作者:Glenn Fiedler
译者:trcj
原文:http://gafferongames.com/game-physics/networked-physics/
引言
大家好,欢迎阅读《游戏物理》系列的最后一篇,我是格伦amp;#8226;菲德勒。
上一篇,我们讨论了如何使用类弹簧力来模拟基本的碰撞、关节和马达。
现在我们将论述如何在网络环境中进行物理模拟。
网络中的物理模拟是多人在线游戏的至宝,而在第一人称射击类游戏里的广泛应用,是其出色表现的佐证。
本文将展示如何把源自第一人称射击游戏的网络处理关键性技巧运用到你的网游中。
第一人称射击游戏
第一人称射击游戏的物理非常简单。世界是静态的,角色的操作也基本上局限于跑、跳与射击之间。为了防止作弊,第一人称射击游戏通常采用客户端-服务器模型,即在服务器上进行物理模拟运算,由客户端将运算结果的近似值展示给玩家。
问题是如何让每个客户端在控制自己玩家的同时,尽可能合理地展示其他玩家的行为。
为了简洁优雅地实现这点,我们对物理模拟进行如下构架:
1. 角色物理完全由玩家输入来驱动;
2. 物理状态可以完全封装在一个结构中;
3. 给定初始状态及相同输入,物理模拟可以合理重现。
如此,我们需要将驱动物理的玩家输入封装到一个结构中,将用于展示玩家行为的状态信息封装到另一个里。下面是一个仅含跑步与跳跃的射击游戏的简单例子:
struct Input
{
bool left;
bool right;
bool forward;
bool back;
bool jump;
};
struct State
{
Vector position;
Vector velocity;
};
接下来我们需要确保在相同初始状态及输入的情况下,物理模拟可以给出尽可能相同的结果。并非要精确到浮点精度,1到2秒内能保持结果基本一致就可以了。
网络编程基础
在解决发送何种数据这个重要问题之前,我想简要讨论一下网络编程的几个小麻烦。其实网络就是一根管道,一点儿也不复杂对吧?错!忽略了网络原理你会痛不欲生。这里有两个你必须要知道的基本知识:
其一,如果你们的网络程序员技术高明,他会使用UDP这个不可靠的数据协议,并在此之上建立某种特定的应用网络层。重点是作为一个物理程序员,你所设计的物理系统从该网络层获取输入及状态信息的同时,更应具备处理丢包的能力。否则在网络不好时,你的系统将会阻塞僵死。
第二,由于带宽所限,你将不得不压缩数据。作为一个物理程序员,你需要谨慎处理此项。准确起见,一些数据绝对不能被缩减,而其他的则不然。任何经有损压缩过的数据都应该能在彼端尽可能地被量化出来以保证双方一致。在不破坏模拟的情况下尽可能高效,是此时应遵循的底线。
更多细节请参见我的最新系列文章《游戏程序员的网络编程》。
客户端输入驱动服务器端物理模拟
我们的服务器与客户端通讯时所使用的基本单元,是一个不可靠的数据块,如果你喜欢,可以称之为一个不可靠的非阻塞远程过程调用(rpc)。非阻塞即客户端发送rpc到服务器后,不会等待服务器执行,而是直接开始执行其余代码。不可靠的意思是尽管客户端有序发送rpc,但是一些调用并不会到达服务器,另一些到达时可能会乱序。这些在设计时都需要考虑进去以适应网络传输层(UDP)的规则。
可见客户端与服务器间的通讯是通过连续的rpc调用来完成的,我将其称之为“输入流”。这个输入流能够处理丢包和乱序的关键性技巧,是在每个rpc包里加入一个时间戳。服务器依据本地时间,忽略掉那些早于该时刻的包,这样可以有效地排除乱序的包。至于那些丢失的包,直接忽略即可。
回到第一人称射击游戏的例子,从客户端发往服务器端的数据结构我们早先已有定义:
struct Input
{
bool left;
bool right;
bool forward;
bool back;
bool jump;
};
class Character
{
public:
void receiveInput(float time, Input input); // rpc method called on server
};
这是在网络中描述一组简单的包含跳跃的地面运动所需要的最基本的数据。如果还想支持玩家射击,你需要在此结构中加上鼠标操作,因为开火也需要在服务器端判断。
注意到我把rpc作为一个类的成员函数了吗?我假设你的网络程序员在UDP层之上设计了某种管道结构,某种能够将rpc和远程客户端一一对应的结构。
接下来,服务器端该怎么处理这些rpc调用呢?基本上它会在一个循环里轮询各个客户端的输入。接收到来自客户端的rpc时,服务器方计算其对应角色的物理状态。这意味着客户端的角色状态会和服务器有些微出入,有的超前,有的滞后。总的来说,不同的角色在保持大致同步的情况下进行着更新。
让我们来看看在服务器代码中这些rpc调用是如何实现的:
void receiveInput(float time, Input input)
{
if ( time lt; currentTime )
return;
const float deltaTime = currentTime - time;
updatePhysics( currentTime, deltaTime, input );
}
这段代码的要点在于,服务器对角色物理状态的更新,是在接收到对应客户端的输入时才进行的。这保证了其对rpc发送过程中产生的延迟或抖动具有容错能力。
客户端演绎服务器的运算结果
现在到了服务器向客户端回发消息的时候。由于要广播给所有的客户端,这里服务器将产生大量的通讯。
在由客户端rpc驱动的每一次物理更新运算完毕之后,服务器需要把最新的物理状态广播给所有的服务器。
这些信息仍然是以不可靠的rpc形式发送给客户端的:
void clientUpdate(float time, Input input, State state)
{
Vector positionDifference = state.position - currentState.position;
float distanceApart = positionDifference.length();
if ( distanceApart gt; 2.0 )
currentState.position = state.position;
else if ( distanceApart gt; 0.1 )
currentState.position += positionDifference * 0.1f;
currentState.velocity = velocity;
currentInput = input;
}
上述代码的意思是:如果双方的位置相差过大(gt;2m),直接将角色强置于服务器位置;如果位置差在10cm以上,角色由当前位置向服务器位置移动10%;否则就不予处理。
由于服务器的更新rpc需要向客户端广播,先仅朝目标移动一小段会产生一个平滑校正的效果,这种技巧被称为指数平滑移动平均线。
这种平滑处理的副作用是会产生一定程度的位置滞后,无奈世间万物皆无十全十美。这里建议仅对直观数据进行平滑处理,如位置、旋转,而诸如速度、角速度之类的衍生数据则大可不必,因为数值骤变在这些衍生数据上的体现并不显眼。
当然,这些只是经验之谈,你应该摸索出最适合自己的做法。
客户端预判
到目前为止,我们的方案是使用客户端输入驱动服务器端进行物理运算,广播运算结果,客户端再据此维护一份服务器的近似值。这套做法很完美,但它有一个主要缺点。延迟!
当玩家按下前方向键时,这个输入需要去服务器上兜一圈,再次回到客户端后玩家的角色才能开始移动。熟悉Quake的同学对这一效果应该不会陌生。这个问题在随后的QuakeWorld里被修正,引入了一种叫做客户端预判的方法。这种技术完全消除了移动延迟并成为之后第一人称射击游戏的标准网络处理技巧。
客户端预判法在玩家输入后直接演算物理结果,而非等待其去服务器兜完那一圈。服务器定期发送正确数据到客户端以供其校对。任何时候,角色的物理状态都以服务器为准,这样一来即便客户端作弊,也只是自欺欺人,服务器的物理体系并不会受到影响。因为所有的游戏逻辑都在服务器端运行,客户端作弊基本上可以被消除。
客户端预测的复杂之处在于如何处理来自服务器的校对信息。由于客户端/服务器的通讯延迟,来自服务器的校对信息总是“过时”的。我们需要回到“过去”校对这些数据,然后依此演算当前的确切位置。
标准做法是在客户端维护一个环形缓冲区用于保存用户输入,每个输入都对应一个从客户端到服务器的rpc调用:
struct Move
{
float time;
Input input;
State state;
};
每当客户端收到一个校对数据,它会将数据中的物理状态与缓冲区中同一时刻的那个物理状态进行比对。如果二者之差超过了某种阈值,客户端会倒回该时刻,在正确数据的基础上对缓冲区中保存的后续输入依次进行重新演算:
const int maximum = 1024;
Move moves[maximum];
void advance(int amp;index)
{
index ++;
if (indexgt;=maximum)
index -= maximum;
}
int head = 0;
int tail = 100; // lets assume 100 moves are currently stored
void clientCorrection(float time, State state, Input input)
{
while (timegt;moves[index].time amp;amp; head!=tail)
advance(head); // discard old moves
if (head!=tail amp;amp; time==moves[head].time)
{
if ((moves[head].state.position-currentState.position).lengthgt;threshold)
{
// rewind and apply correction
currentTime = time;
currentState = state;
currentInput = input;
advance(head); // discard corrected move
int index = head;
while (index!=tail)
{
const float deltaTime = moves[index].time - currentTime;
updatePhysics(currentTime, deltaTime, currentInput);
currentTime = moves[index].time;
currentInput = moves[index].input;
moves[index].state = currentState;
advance(index);
}
}
}
}
有时,丢包和乱序会导致服务器的输入与客户端所存不一致。这种情况下进行回倒和重算会将角色强置到正确位置。这种强置瞬移过于明显,所以我们可以通过与之前相同的平滑校进行处理。该处理应在回倒和重算结束后进行。
客户端预判的缺点
客户端预判法似乎完美得让人难以置信。一个简单技巧就能让我们完全消除延迟。但有没有什么代价呢?答案就是每当服务器上有两个物理体相互作用时,就会导致强置瞬移。为什么会这样?事实上,客户端使用自己掌握的数值进行物理演绎,却得到了和服务器截然不同的结果。瞬移。
在我们仅有跑步和跳跃的简单fps游戏里,一名玩家穿过另一名玩家、企图站在另一名玩家头顶、或者被爆炸掀翻,这种情况都会发生。说到底,任何非玩家输入引起的物理状态改变都会导致瞬移。实际上没有任何办法能避免舍弃客户端预判结果,而直接采用服务器结果。
这让我想起一个有趣的问题。从静态世界中玩家移动互射的第一人称射击游戏,到动态世界里玩家与他人及周边环境交互,网游正在悄然进化。鉴于这种趋势,我愿大胆预测:本文介绍的客户端预判法,可能很快就会过时。
联网物理概览
到目前为止,本文展示的解决方案在第一人称射击游戏中都运行良好。其最主要的限制在于每个客户端对每个角色都有一个明确的所有权,意即绝大多数情况下,该客户端是影响该角色物理的唯一因素。
这种简化性的假设,是第一人称射击游戏惯用技术的基础。如果你的物理系统与此相似,那么这些技巧正合你胃口。例如,每个玩家控制一辆车的赛车游戏中,你只需稍微加入点额外的物理状态和用户输入,扩展一下该系统即可。
但如果你想制作的物理游戏没有明确的物体归属。比如,设想有一堆方块,玩家可以点击拖动任意方块。此时没有哪个物体是特属某个玩家的,甚至有可能多个玩家同时拖拽同一方块。也许一个玩家站在方块上面,而其他玩家正决定开车撞飞这堆方块!够复杂吧!
这种情况需要引入更多的技术。客户端预判法很明显是不能胜任的,因为它会导致严重的瞬移。问题的关键在于服务器不能再等收到客户端输入后才进行物理更新,因为物体不再像第一人称射击游戏里那样,明确归属于某个客户端。
这意味着服务器端的物理更新看起来更接近于传统做法,所有物体都依据各自最终收到的客户端输入进行同步更新。这让服务器对诸如包延迟、包堆积等网络问题更加敏感,同时需要更多的工作以确保客户端和服务器同步。
解决这些问题将是一个挑战,我也希望以后能给出实现多人玩转方块堆的解决方案及源代码。
总结
在网游中进行物理模拟比较复杂,掌握第一人称射击游戏中使用的核心技术会让它理解起来更容易一些。
我制作了一个演示以与本文配套,演示中我用了一个立方体来代替FPS角色,你可以操纵立方体进行跑步及跳跃,抱歉不能射击!
演示程序里有很多可视化内容帮你理解回倒、重算、平滑处理等概念,现在就下载下来把玩一番吧!
锐亚教育,游戏开发论坛|游戏制作人|游戏策划|游戏开发|独立游戏|游戏产业|游戏研发|游戏运营| unity|unity3d|unity3d官网|unity3d 教程|金融帝国3|8k8k8k|mcafee8.5i|游戏蛮牛|蛮牛 unity|蛮牛
- 还没有人评论,欢迎说说您的想法!