【译】《帝国时代》中的网络编程

写在前面

《原E文》的题目比较长, 全译过来是: "28.8k网络环境下的1500个弓手: 《帝国时代》中的网络编程及未来".

因为原文的写作时间比较早, 所以文中提到的很多硬件和网络环境, 在现在看都已经过时了, 但是并不妨碍我们学习它的设计理念和网络模型.

番外: 在 2010 年德国科隆电玩展(Gamecom 2010)开幕时, 微软宣布了《帝国时代》系列的最新作《帝国时代Online》. 该游戏是由机器人工作室 Robot Entertainment 所开发, 该工作室是由原全效工作室 Ensemble Studios 的主要成员所成立.

介绍

本文描述了《帝国时代 1》和《2》中的设计架构, 实现方法, 以及多人玩法(网络)的编程经验, 并讨论了 Ensemble 工作室在游戏引擎中的网络实现部分.

《帝国时代》多人玩法-设计目标

当1996年, 开始写《帝国时代》的多人玩法部分代码时, 为了追求游戏体验, 我们明确了需要满足下面一些目标:

  • 排除包含了大量单位的历史战役.
  • 支持8玩家对战.
  • 保证在局域网, modem 之间, 和 Internet 上游戏平滑.
  • 支持的目标平台: 16M Pentium 90, 28.8k调制解调器(modem).
  • 通信系统要支持现有的引擎(Genie).
  • 在低配机器上,目标帧率 15 fps.

Genie 引擎是一款 2D 单线程引擎, 在单人游戏模式下运行的非常好. 游戏中的精灵根据调色板, 被渲染成 256 色, 随机生成的地图上涵盖了数以千计的对象: 可以被砍伐的树, 跳跃的羚羊, 等等. 引擎的消耗: 30% 图形渲染, 30% AI和寻路, 30% 逻辑帧.

在很早期的阶段, 引擎很稳定: 多人间通信代码和现有的代码耦合在一起运行.

问题来了: 每一帧的时间差别巨大, 如果玩家在观察单位, 或者坐在位置地形中, 或者 AI 计算复杂的策略时, 每帧的时间波动可能达到 200 毫秒.

几个简单的计算就能表明: 即便是传输很小的单位数据集合, 并实时更新, 也会严重的限制玩家间交互的单位和对象的数量. 即使只传输 X, Y 坐标, 状态, 行为, 方向, 伤害等, 顶多也只能保证游戏中的移动单位到 250 个.

我们想通过发射器, 弓箭手和战士毁灭一座被大海和战船围困的希腊城市, 显然, 实现这样的场景需要另辟蹊径.

同步

我们希望在每台物理机器上做相同的仿真, 执行一样的指令集合, 而不仅是传输游戏中的每个单位的状态. PC 能在游戏中同步游戏时钟, 允许玩家发出指令, 然后在相同的时间里以相同的方式执行这些指令, 最终有完全一样的游戏进程.

这是一个棘手的同步方式, 在初期很困难, 但是在其他地方会带来意想不到的好处.

基础模型的演进

从最简单的概念上来说, 同步看上去很简单. 某些游戏中, 就实现了锁帧模拟 (lock-step) 和固定的游戏时钟.

虽然这种解决方案下, 同时移动成百上千的对象仍然会带来问题 (同步数据量过大), 但是 Internet 20-1000 ms 的延迟的下依然是可行的, 并且能解决每帧处理时间的波动.

发送指令, 确认收包, 然后在下一帧之前处理消息包: 这是个糟糕的方法, 游戏画面会因此而变得一卡一卡, 游戏进程在跑的同时, 需要能一套机制在后台处理通信.

这里是说, 要将渲染逻辑, 和网络通信以及指令逻辑分开处理. 事实上, 《帝国》中的通信和渲染就是分开处理的.

Mark 使用了一个指令标记系统, 每条指令都在 2 个通信回合 (communication turn) 后执行.

这样, 如果在第 1000 周期内发出的指令, 将在第 1002 通信回合内执行. 如图, 第 1001 通信回合内执行的命令是第999通信回合内发出的. 这种机制可以在游戏渲染的同时, 保证消息的接受, 确认, 和准备处理过程.

通常每个通信回合的时间跨度为 200 ms, 在这个时间内发送产生的指令. 200 ms 之后进入新的通信回合. 在游戏过程中的某一通信回合过程中, 接收和存储的指令在下一个周期内执行, 发送的指令在下下个周期内执行.

这实际上是一套缓存机制, 按照事先的约定, 延迟执行缓存的指令集, 尽量保证同步.

速度控制

既然游戏模拟的输入完全一样, 那么游戏速度最快只能和网络中最慢的机器(处理通信, 渲染, 发送新指令一系列操作)一样.

速度控制是说, 我们通过修改通信回合的跨度时间, 在网络延迟波动和不同的机器处理速度下, 尽量保持游戏画面的平滑.

游戏中感觉到卡, 主要是两个原因:

  • 其中一台机器的帧率下降, 其他所有的机器在处理完网络消息, 渲染完画面之后, 会等待这台机器. 即使很短暂的等待也会带来卡的感觉.
  • 通信延迟, 网络延迟和丢包也会使玩家相互等待, 直到收到足够的数据来完成当前通信回合.

每个客户端会根据平均处理时间统计一个游戏帧率, 因为玩家的视野, 单位数量, 地图大小和其他因素, 所以计算得到的帧率会随游戏进程而不同. 这个帧率会在每个通信回合结束的消息中发送.

每个 client 也会周期性的和其他 client 之间测量往返的 ping 值. 在每个通信回合结束的消息中会带上和其他客户端之间的最大 ping 值. (速度控制一共占了2个字节).

每一个通信回合中, 指定的主机会分析回合结束信息, 并计算出一个目标游戏帧率, 根据网络延迟做出调整. 主机会通知所有的 client 新的游戏帧率和通信回合的时间跨度.

下面是几种不同情况下的通信回合示意:

  1. 正常情况: 通信回合 200 ms, 处理网络消息 50 ms, 游戏 20 帧/秒

  1. 高网络延迟: 通信回合 1000 ms, 处理网络消息 50 ms, 游戏 20 帧/秒

  1. 机器性能差的情况下: 通信回合 200 ms, 处理网络消息 100 ms, 游戏 10 帧/秒

通信回合的时间跨度差不多等于一个消息的 RTT, 在这段时间内, 根据最慢的物理机器的游戏速度, 通信回合分割成不同的帧.

通信回合时间 T, 游戏每帧耗时 t, T = max(RTT, 2 * t)

  • T 一定是 t 的倍数, 假设 T = n * k.
  • T 时间内, n - 1 帧是游戏逻辑帧, 剩下的 1 帧统一处理网络包, 同步游戏指令.
  • 在小霸王机器上, 最坏可能 T = 2 * t

《帝国》中对通信回合的时间跨度做加权处理, 可以保证在网络延迟变化时动态调整, 慢慢趋向于一个可以保证游戏持续的最佳稳定速度.

游戏只有在最坏的情况(突发的指令延迟)下才会变卡, 但一般几毫秒之后, 就可以调整回一个可能的最佳游戏速度. 在不断适应变化的环境的同时, 提供了一个最平滑的游戏体验.

可靠传输

《帝国》在网络层使用了 UDP 协议, 每个客户端都会做组包, 丢包检测和重传.

每个消息中都包括了通信回合的序号和消息序号, 如果收到了上一个通信回合的消息, 则直接丢弃, 其他的消息都存储并执行.

因为 UDP 的天然特性, Mark 认为, 任何值得怀疑的消息都被丢弃.

如果收到的消息乱序了, 接收者丢弃该消息, 并立即发一个重传请求.

如果超时没有收到 ack 消息, 发送者不必等到重传请求, 就应该立即重传该消息.

潜在收益

游戏的结果取决于所有用户执行完全一样的仿真, 这就使得 hack 和作弊异常困难.

如果仿真过程被标记为"不同步", 游戏会终止.

在本地仍然可能作弊并获知信息, 不过这个通过后续的补丁和版本来控制.

从安全性上来说, 获益巨大.

潜在问题

最初开看, 两份完全一样的代码, 应该很明显, 有同样的执行过程和结果, 然而事实并非如此.

Microsoft 的产品经理, Tim Znamenacek, 早期告诉Mark, "在任何一个项目中, 一旦和网络相关, 都会存在顽固的 bug —— 我认为网络不同步就是这个bug".

他是正确的. 发现不同步错误的困难在于: 很微小的差异会随着时间而被放大.

在创建随机地图时, 一头鹿稍微有点不对齐, 觅食的路径也偏差了一些, 几分钟之后, 农夫的寻路就会偏差一些, 攻击可能会 miss, 没有掉落肉, 所以, 表现出来的差别, 例如食物总量的不同, 在追溯其根本原因时会让人觉得很困惑.

我们尽可能的检查游戏世界, 对象, 寻路, 目标等其他系统, 但是似乎总是不够.

巨大的网络包(50MB) 和众多的世界对象, 让问题变得更复杂.

经验教训

在开发《帝国》网络系统中, 我们积累了一些关键的经验, 可以应用在任一款多人游戏系统开发中.

了解用户

研究用户是关键, 要了解他们在多人游戏的性能表现, 游戏感滞后, 命令延迟等方面的期望. 每个游戏的风格各异, 在特定的游戏玩法和控制下, 你需要了解如何做才正确.

在开发早期, Mark 和首席设计师坐下来, 并建立了通信延迟的原型(整个开发过程中, 一直重新审视). 因为单机游戏可以正常运行, 很容易就可以模拟出不同范围的指令延迟, 并获得玩家的不同反馈: 正常, 有点慢, 感觉卡, 很不爽.

对于 RTS 游戏来说, 250 ms 内的指令延迟不明显, 250 - 500 ms 还是可以玩的, 但是如果超过 500 ms, 就开始感觉到延迟了. 有趣的是, 玩家会习惯一种"游戏节奏", 对从点击到单位开始有反馈之间的延迟, 有一个心里预期. 稳定的比较慢的延迟, 比忽快忽慢的延迟, 更容易接受(前提是 80 - 500 ms 内), 在这种情况下, 稳定的 500 ms 的延迟也是能玩的, 但是一个变化的网络延迟玩家就会感觉卡, 甚至很不爽了.

根据这些原则, 我们在程序上做了很多努力来保持平滑 —— 选择一个相对较长的通信回合时间更好, 而不是说尽可能的快. 任何速度的变化都要循序渐进.

我们也统计了玩家的操作命令 —— 通常玩家每 1.5 - 2 秒会发出一条指令: 移动, 攻击, 砍树. 在激烈战斗时, 会偶尔会达到每秒 3, 4 条命令. 因为我们的游戏是发展型的, 一般中后期的通信需求会达到最大.

花时间去研究用户的游戏行为, 能有助于我们提升网络性能. 在《帝国》中, 在玩家在兴奋的攻击时疯狂点击鼠标, 会产生大量的命令, 形成波峰 —— 在移动大量的单位时, 也一样. 一个简单的过滤器, 丢弃掉位置重复的移动命令, 可以大幅度的减少这种行为的影响.

总结一下, 用户反馈能收获到:

  1. 了解用户在对游戏延迟的期望.
  2. 早期建立多人游戏原型.
  3. 观察消耗性能的游戏行为.

统计为王

如果在早期加入统计系统, 测试人员可以读取统计信息, 并了解在网络引擎的掩盖下发生了什么, 可能会有另人惊讶的发现.

经验: 《帝国》通信系统的一些问题发生在 Mark 过早的移除统计系统后, 不能重新验证后加入的消息(长度和频率). 一些未经检测的事情: 例如偶尔的 AI 竞争条件, 复杂的路径计算, 糟糕的命令包结构, 都可能在导致巨大的性能问题.

在要到达边界条件时, 让系统通知到测试和开发人员 —— 程序猿和测试人员可以在开发时注意到, 哪些任务会对系统造成压力, 并尽早采取措施.

花点时间来给测试人员培训通信系统工作的原理, 并解释统计结果 —— 否则当网络代码不可避免的遇到了奇怪的问题时, 你有可能会对他们关注的事情感到诧异.

总结一下, 统计信息应该:

  1. 人性化可读, 测试人员能够理解.
  2. 能反映瓶颈, 性能, 和问题.
  3. 整体影响低(对性能), 能稳定持续运行.

培训开发者

让习惯开发单机应用的程序猿, 开始思考命令的产生, 接受, 处理是一个复杂的异步问题, 他们很容易就忘记: 请求的事件不一定会发生, 或者会在发出命令的几秒之后才会发生; 发送和接受时, 都必须对命令检查有效性.

在同步模型中, 程序猿必须意识到, 代码一定不能依赖于本地(例如有空余时间, 特殊的硬件, 或者其他不同的设置). 所有机器上的代码路径必须完全匹配. 举个例子, 随机地形会导致游戏的不同行为, 我们要在游戏中为伪随机数生成器重新生成种子, 保证随机数的一致性, 而非改变游戏的过程.

其他经验

这个就比较广泛了, 但是如果你依赖与一个第三方的网络代码(例如我们用的 DirectPlay), 写一个独立的测试用例, 来验证他们所谓的"可靠传输", "保证收发包有序", 保证产品不会有隐藏的性能瓶颈, 或者在处理网络通信时发生异常.

准备创建一个模拟环境和压力模拟器. 我们用三个不同的最小测试用例结束, 所有的测试都用来凸显问题: 例如连接溢出 (connection flood), 并发连接问题, 以及数据包率下降.

在开发过程中, 尽早的用 modem 测试, 并持续整个开发过程(很痛苦). 因为这是个难以定位的问题: 突然的性能下降, 是因为 ISP, 游戏, 通信软件, modem 还是对端服务? 人们在习惯于 LAN 中的速度时, 真的不愿意再去拨号连接. 最重要的是, 你需要保证在拨号连接下测试, 和在 LAN 下测试一样充满激情.

《帝国2》的改进

《帝国2》中, 我们加了一些多人游戏的新特性, 例如游戏录像, 文件传输, 持久的 Zone 内统计跟踪. 还有对原系统的改进: 优化了 DirectPlay 的整合, 修改速度控制的 bug, 和《帝国1》发布之后出现的一些性能问题.

最开始加入游戏录像功能, 是为了 debug. 游戏录像在粉丝网站上非常流行: 因为玩家之间可以交换录像, 分析战略, 观看牛逼录像, 审视自己的录像.

集成的 Zone 内比赛匹配系统, 仅限于游戏直连的玩家. 在《帝国2》中, 我们扩展到能允许参数启动, 并能做统计报告. 尽管并不是一个完全的自内而外的系统, 我们利用了 DirectPlay 的大厅登陆功能, 允许 Zone 能设置一些特定的游戏设置, 并在游戏开始之后"锁住"这些功能. 这让玩家能更好的找到想玩的比赛: 因为可以在匹配的时候就能看到游戏设置, 而不用等到启动游戏设置的界面. 在后台, 我们实现了持久化的统计和报告功能: 在 Zone 中提供了一个公用的结构, 在比赛结束的时候填写数据并上传, 用来做用户评级和排名, 并发布到网站上.

实际上就是 bug 修复, 以及一些用户体验的优化, 包括社区的建设.

RTS3 多人游戏: 目标

RTS3 是 Ensemble 工作室下一代即时策略游戏的代号. RTS3 的设计建立在《帝国》系列游戏成功的基础上, 并包含了一些新特性和多人游戏的需求:

  • 基于《帝国》系列, 设计目标包括了: 联网游戏, 大场景地图, 数以千计的可操作单位.
  • 3D, RTS3 是一款 3D 游戏, 包括插值动画, 以及非分面处理的 (non-faceted) 单位位置和旋转.
  • 更多的玩家, 可能支持不止同时 8 个玩家对战.
  • TCP/IP 支持, 56K 的 TCP/IP 网络连接是我们的首要目标.
  • 家庭网络支持, 支持终端用户在家庭网络中的配置, 包括防火墙和 NAT 设置.

在 RTS3 中, 我们很早就决定了继续使用和《帝国》系列相同的底层网络同步模型, 因为 RTS3 的设计, 同样能在这种架构下发挥优势. 《帝国》系列中, 我们依赖 DirectPlay 实现会话和传输管理服务, 但在 RTS3 中, 我们决定只用最基础的 socket 例程, 创建一个网络库作为基础.

全 3D 的世界, 意味着我们要对帧率和多人游戏时的游戏整体平滑度更加敏感. 然后, 这也意味着每帧的更新时间和帧率更容易发生波动, 要花更多的时间去做渲染. 在 Genie 引擎中, 分面处理单位旋转和游戏动画也都按帧率锁帧的 —— 如果允许单位任意旋转, 以及对游戏画面平滑的保证, 会使游戏对网络延迟和游戏更新速率波动, 在视觉上更加敏感.

总结《帝国2》的开发过程, 我们希望能解决这些关键领域的前期设计工作和工具链, 能在调试阶段更方便. 我们也意识到, 迭代测试过程对我们的游戏设计非常重要, 所以需要尽早能让多人在线游戏.

RTS3 通信架构

面向对象

RTS3 的网络架构是强面向对象的, 支持多种网络配置(不同的系统和协议下), 能真正发挥了面向对象设计的优势.

对不同的协议和不同的拓扑网络, 尽量减少代码量. 大部分的功能放在父类对象中来实现. 实现新的协议, 只需要继承网络对象, 并加上特定的协议代码即可(例如 client 和 session, 只要根据协议, 加一些代码实现就行). 而系统中的其他对象不需要改变, 因为接口已经在父类中定义.

我们还采用了多重继承(例如 channels, peer, repeater). 对于不太频繁的通知, 用了虚函数而非回调. (OO 的多态)

端对端拓扑

Genie 引擎支持 P2P 的网络拓扑结构, 所有的客户端在网络中呈星状分布, 两两之间能通信. 在 RTS3 中, 我们继续使用这种拓扑结构, 因为在网络同步中可以带来方便.

P2P 的优势:

  • 客户端之间是直接通信, 相对于从服务器中转, 减少了网络延迟.
  • 没有单点故障, 任何一个客户端断线, 不会影响游戏的正常进行.

P2P 的劣势}:

  • 网状的连接, 连接数随客户端增加而迅速增多, 即更多的潜在失败可能性.
  • 这种方式下, 某些 NAT 配置无法支持.

网络库

我们设计 RTS3 通信架构的目标, 是创建一个专门应用于策略游戏的系统, 同时, 也希望能构建内部工具, 支持未来的游戏. 为了实现这个目标, 我们创建了一个分层的体系结构, 支持游戏级别的对象, 例如 client, session, 也支持底层的通信对象, 例如 link, 网络地址.

RTS3 基于下一代 BANG! 引擎, 采用了模块化架构和一些组件库, 例如声音库, 渲染库和网络库. 网络子系统在这里作为 BANG! 引擎的一个子模块(包括各种内部工具). 网络模块分成了4层(不一定完全在游戏中应用), 而不是 OSI 的7层模型.

Socks,第一层

提供了基本的 Socket C API, 并抽象出一个跨平台的底层网络例程集合. 接口与 Berkley Socket 类似. Socks 层, 主要被网络库的上层调用, 而非直接在应用代码中使用.

Link,第二层

提供传输层服务. 这层中的对象, 例如 Link, Listener, NetworkAddress, 以及 Packet, 包括了在建立连接和传输消息时的各部分.

  • Packet, 基础的消息结构, 可继承扩展, 在通过 Link 传输时能自动的序列化和反序列化. 就是协议.
  • Link, 网络端点之间的连接, 如果两端都在同一台机器上也可以是一个环回链路. 提供了发送和接受方法, 处理 Packet 和 void* 数据.
  • Listener, 一个 Link 生成器, 监听网络连接, 当连接建立时生成一个 Link.
  • Data Stream, 在 Link 上传输的可控数据流, 例如, 用于文件传输.
  • Net Address, 协议无关的网络地址对象.
  • Ping, 一个 ping 实例, 可以获得给定 Link 的网络延迟.

MultiPlayer, 第三层

提供了网络库 API 中的最上层对象和例程, 属于 RTS3 接口层. 在 BANG! 引擎的网络库中的最有意思的对象就在这一层了. 这层的 API 提供了一组游戏层交互的对象, 但我们保持了一个游戏无关的实现方法.

  • Client: 网络端点的抽象, 可以配置成远端 Client (Link), 或者本地 Client(lo Link). Client 不是直接创建的, 而是由 Session 对象生成的.
  • Session: 监听网络, 管理 Client 和 Link 的对象. Session 包含了所有 MultiPlayer 层的对象. 应用的时候, 只需要在代码中代码中调用 host() 或者 join() 即可, 传一个本地或者远端地址, Session 自动处理剩下的部分. Session的职责包括了: 创建, 删除 Clients, 通知 Session 事件, 分发网络状况到相关的对象.
  • Channel 和有序 Channel: 该对象代表了一个虚拟的消息管道, 在 Channel 上发送的消息能自动的区分, 并在对端的 Channel 对象中接收. 有序 Channel 和 TimeSync 对象一起, 保证所有对端 Channel 上收包的顺序一致.
  • Shared Data: 代表了所有 Client 之间共享的数据集合. 可以继承该对象, 创建包含了自定义数据类型的实例, 通过内部方法, 能保证保证网络上所有 Clients 的数据的自动和同步更新.
  • Time Sync:管理一个 Session 中的所有 Clients 网络平滑同步的过程.

Game Communications, 第四层

通信层, 属于 RTS3, 包含在游戏代码中, 游戏内部通过该层与网络库交互. 通信层提供了大量的实用工具, 可以用来创建和管理 MultiPlayer 层网络对象. 并尝试将多人游戏的需求, 归结成简单易用的接口(即 RPC 调用).

新特性和更好的工具

改进的同步系统

在《帝国》的开发团队中, 恐怕没有人会怀疑需要一个最好的同步工具. 在任何项目中, 在你回顾开发过程时, 总有一些地方花了很多时间, 而事实上如果做好前期工作, 可能只需要花一点时间就能搞定. 在开始 RTS3 的开发之前, 同步调试可能是其中优先级最高的一项.

RTS3 同步追踪系统, 主要是针对快速掉头的同步 bug. 在开发过程中, 其他比较看重的, 包括了开发者的易用性, 处理大量同步数据的能力, 能否在 Release 版本中完全编译同步代码, 能否通过修改配置文件来而不用重新编译来测试.

在 RTS3 中,同步检查通过下面两组宏定义来实现:

#define syncRandCode(userinfo) gSync->addCodeSync(cRandSync, userinfo, __FILE__, __LINE__)
#define syncRandData(userinfo, v) gSync->addDataSync(cRandSync, v, userinfo, __FILE__, __LINE__)

(每个同步"标签", 有一组宏, 一个标签代表一个指定系统要做同步. 在这个例子中, 是随机数生成器: cRandSync). 这组宏的参数包括了一个 userinfo 的 string 参数, 表明同步的对象. 例如, 实际调用时可能如下:

syncRandCode("syncing the random seed", seed);

同步控制台命令和配置变量

这对于开发过程来说, 意义重大. 控制台命令一般是简单的函数调用, 可以通过启动配置文件, 在游戏的控制台中, 或者 UI 的钩子中, 调用任意的游戏功能. 配置变量被称为数据类型, 通过简单的 get, set, define 和 toggle 函数, 来做各种测试和配置参数.

Paul 扩展了一个支持对人游戏的控制台命令和配置变量系统. 我们可以很容易将一个普通的配置变量(例如, enableCheating), 通过添加一个标记, 加入到对人游戏的配置变量. 如果使用了这个标记, 在多人游戏中就会传输这个配置变量, 同步的游戏进程也会跟这个值有关(例如, 是否允许资源免费). 多人游戏的控制台命令也是类似的概念: 调用一个多人游戏下的控制台命令, 会传输到网络中的玩家, 并同步执行.

通过应用这两个工具, 开发者可以很简单的使用多人系统, 而不用写一行代码. 他们能快速的添加测试工具和配置, 并加入到网络环境中.

总结

点对点的网络同步模型, 在《帝国》系列游戏中获得了成功. 关键之处在于明白花时间在创建工具和技术上的重要性(例如同步和网络统计). 证明了在实时战略游戏上应用这种架构的可行性. 后续在 RTS3 中做的改进, 保证了多人游戏的体验和单机时几乎没有区别, 除非在最烂的网络环境下.