总结 Overwatch Gameplay Architecture and Netcode 守望先锋的游戏架构与网络代码
原视频
https://www.youtube.com/watch?v=W3aieHjyNvw&list=PLG9sbQS_QV1-6MnaNaN-uO5fUkaTocN58&index=4&t=2901s
翻译链接
https://www.lfzxb.top/ow-gdc-gameplay-architecture-and-netcode/
还是看视频和中文解析吧。我自己翻译起来太麻烦了,主要是截图浪费时间, 上面的翻译链接已经足够好了。
这里我截图并且总结一下关键点。
选择ECS
和传统的Actor model和最近的 Component model不同,ECS是一个独立的结构 Entity是一个ID,类似数据库查询的一个ID,可以找到这个ID对应的所有Component数据。
ECS 架构能够在快速增长的代码库上管理复杂性
ECS的结构解释
Component是一个纯数据(State状态),无任何逻辑(Behaviour行为)
System是一个纯逻辑,无任何数据

在一个World中包含N个System和M个Entity

多个System的Tick有Order执行
一个System可能需要多个Component,去组成一个ArchType
System并不关心Entity,System只关注Entity上的部分Component Tuple

下图代码举例,去解释上面的System和Component关系,System Behaviour如何与Component data关联起来

下图:World Entity System 三者之间的关系
EntityAdmin(World)里有
1.System数组
2.EntityId的映射,并且通过这个ID可以找到Entity包含的所有Component数。同时我们要管理不同Component的生命周期
3.System里会关联多个Component,如下代码图


举反例说明使用System Behaviour执行AFK判断的优点:
下面代码图是ECS实现的Connection State的判断

如果使用OOP或者ComponentModel,我们要把Connection State的更新逻辑放到哪个模块呢?这么多和Connection有耦合的模块。
ConnectionState不是Behaviour,只是State。
OOP的每个Component是Behaviour+State

制作ECS遇到了2个问题,1.system里存储数据了 2.system里耦合其他system,并且需要读取system的数据
创建了一个 global Entity admin,然后可以获取system。
这造成了扩展困难。
当在实现回放的功能时,这个问题暴露出来了。

两个world: liveGame和replayGame
replayGame: server发送一个8-12秒的网络数据到client,然后像正常游戏一样工作。细节需要看另外一个分享。这里没有展开

取消了之前的做法:创建一个Shared EntityAdmin
新的规则:只有一个Admin (world).
这个Admin里可以有 Singleion Components。这些Singleion Components存在一个匿名的single Entity上。然后Admin可以直接访问这个AnonymousSingleEntiy,去获取上面的SignleComponent

举例 Input的数据作为一个SingleComponent,其他System读取这个InputComponent数据,去同步给Server 去执行本地的移动等。
SingleComponent大改占有了40%

在使用了ECSingletonInput数据后,我们就不存在System之间的耦合了

多个system Behaviour去共享static function。
这里的 side effects,我觉得可以理解为“写数据、发送网络请求”之类的意思,可能会影响这个Component数据的值。
举例:服务器和client都会调用相同的 static movement funciton,去修改entity的 position
两种共享utility functions的情况

简化你的行为(SIMPLIFY YOUR BEHAVIORS)
-
在单一调用点表达行为(Express in a single call site) 把一个行为的逻辑集中在一个函数调用的地方表达出来,而不是在很多地方分散处理。
-
将主要副作用局部化到该调用点(Localize major side effects to that call site) 副作用是指函数执行时产生的额外影响,比如修改状态、打印日志、发出网络请求等。这里强调的是,**把这些副作用集中在一个地方(调用点)**处理,而不是让副作用“到处散落”

延迟处理 Deferment
如果多个system都要用到一个Component数据
我们可以使用singleComponent, pending后延迟处理这个数据带来的效果
存储并推迟调用:storing the state required to invoke major side

下图是举例说明上面的延迟处理概念:
Singleton Contact 单例Contact效果
作者举例:枪械和贴花在墙面上的特效,为了避免在同一片区域多种贴花效果z fighting然后和TA battle的问题,使用了下图的方式。
1.先有个PendingContact数组
2.有耦合的system点,向pendingContact数组里添加数据
3.在最终的ResolveContactSystem里执行(lod mix等)效果
有些数据流的感觉,前面都是修改数据,最后才归纳所有数据,分析执行,得到最后的效果
优点除了解除耦合外,如果做异步加载也很容易(大量相同的effect延迟分段生成)。

Overwatch左上角的信息
-
FPS: 70
当前的帧率(Frames Per Second)为 70,表示画面每秒刷新 70 次。数值越高,游戏越流畅。 -
PNG: 248 ms
表示 Ping 值,即你客户端到服务器之间发送一个数据包并接收到回复的时间。
248ms 有点偏高,可能会感到延迟。 -
RTT: 266 ms
Round Trip Time,也就是完整的来回耗时,理论上 RTT ≈ Ping,但有时候 RTT 会包括更多网络开销和排队延迟。 -
IND: 24 ms
这是 Interpolation Delay,也称为插值延迟,是客户端用来平滑补偿网络抖动的延迟。值越大说明你看到的画面越滞后于真实情况。24ms 属于正常范围。

下图是接下来要讲的网络方面的大纲:
BREADTH(广度)
-
Novel techniques 使用了一些新颖的技术手段。
-
Reduced complexity through ECS 通过使用 ECS(实体-组件-系统)架构,来减少复杂性。
ECS 是一种常用于游戏开发的架构模式,有助于提升性能和模块化程度。在网络同步方面,ECS 也能帮助分离数据与逻辑,简化状态同步。 -
不会讲的内容(Not covering):
-
General replication of entities 不会讲通用的实体同步机制(比如 transform 复制那种标准操作),因为这不是本次主题重点。
-
Remote entity interpolation 不会涉及远程实体插值的细节。这通常用于客户端平滑远程玩家的位置以应对网络延迟。
-
Details of backwards reconciliation 不会讲反向校正的细节,即在客户端预测错了之后,如何回滚并重放输入来修正状态。这属于网络同步中的高级话题。
-

Determinism
DETERMINISM(确定性),主要讲的是在网络游戏中如何实现“客户端与服务器状态一致性”的关键策略
Synchronized Clock(同步时钟)
所有客户端和服务器使用一个统一同步的时钟基准。
这样才能保证大家在相同的逻辑时间点处理同样的输入,例如第 100 帧时所有人执行的是同一批命令。
-
在 lockstep 或 deterministic replay(决定性回放)系统中尤为关键;
-
通常会通过 NTP、Ping 反馈、帧号对齐等方式校准时钟。
Fixed Update(固定更新步长) 使用固定时间步长(如每 16ms 更新一次),而不是帧率驱动逻辑。
Quantization(量化)将浮点值(比如位置、角度)进行离散化处理,通常是为了:
-
降低浮点误差对状态同步的影响;
-
在重播/回放或状态同步中保证一致性;
-
降低网络带宽(压缩为 8bit、16bit 等)。
-
16ms command frames(16毫秒命令帧)意味着客户端每 16ms 发送一次输入命令,服务器也以同样频率接收和处理这些命令。这个节奏可以协调多个客户端的输入并保持同步。
Overwatch中,虽然它是一个 高动作性、非 lockstep 的 FPS 游戏,但为了优化同步效率、减少带宽、提高一致性,他们仍然在某些逻辑层中应用了“确定性设计思想”。
在游戏逻辑中,时间被量化成命令帧

下图说了是怎么把time量化成fixed frame id的
-
Loop clock => Fixed frames 循环时钟(真实运行时间)映射为固定帧(逻辑帧)
-
Add loop clock time to previous remainder 将当前循环的时钟时间加入上次未处理完的剩余时间
-
Increment command frame 如果累计时间足够,推进一个逻辑命令帧
-
Roll-over remainder to next frame 多出来的时间保留到下一个循环处理
-
-
System::UpdateFixed 所有固定步长逻辑在
System::UpdateFixed()中执行

下图模拟了正常情况下Client发送数据给Server的方式,可以看出Client是领先Sever并且提前执行预测。
下图的Half RTT很好理解
下图Buffer的话是Server Command Buffer。
server这里cache了一帧,server读取buffer里的Command,去权威的执行client的行为,并加入到要发送的快照队列里去


接下来三张图:说明服务器下发的快照和Client预测的运动冲突后,Client需要回滚执行权威的服务器版本,从第17帧开始
图1:服务器计算第17帧的行为,玩家被冰冻了

当第17帧的快照到达Client,Client的预测和服务器权威快照有冲突

Client从第17帧开始回滚执行

下图:Server没有正常的接收到Client的Command请求,耗尽了CommandBuffer里的数据。
Server简单的使用之前的输入数据进行Fake 模拟,并且回包中标记为当前是LostCommand的,接下来会告诉client

客户端收到这个输入丢失的标记后,会提高send频率,也许类似unity fixedupdate 修改fixedDeltaTime之类的
Server同时会增大Command buffer
如下面2张图


经典的发送冗余input, 大大降低输入丢包。如下图

大致提了Ability也是可以预测并且根据时间去回滚的,工作原理和上面的movement差不多,需要看另外一个GDC分享,这里没有细说。
Hit Registeration 命中检测
首先伤害的结算使用延迟处理 Deferment在服务器进行。就像上面提到的effect contact一样。
命中的预测是在client进行的。
服务器收到命中请求后,会在对应的时刻(倒回rewound)效验

下图举例说明服务器倒回Rewound去检测命中和产生伤害。
首先在0.5s内给每个 敌人目标设置时间段的box,并且检测shooting raycast是否和这个box相交。
如果相交,再rewound指定的 敌人。这也算一种优化吧。并不是rewound所有的entity。只关注可能的部分。

下图也是一个例子。在模拟60%丢包率情况下,
绿色和蓝色是client下的 target和bullet
黄色和紫色是server下的模拟。

当RTT超过220,客户端预测命中开始变得不太有效了。视频的40分钟左右。
不在进行预测命中,直接交给Server去处理。
原因:客户端上的Target外插值太久了,虽然看起来是及时响应的(及时反馈了飙血Effect,但是真正的HP bar和命中点并没有显示),但客户端预测命中实际上没什么效果。
retrospective 回顾
主要是讲了ecs的一些使用上的优点
1.定义ComponentTuple,清晰的知道要做什么

2.明确的知道system需要哪些数据。进行并行化。举例:transform组件,明确的知道哪些System只是Read Transform,然后并行安全。
Entity lifeTime
延迟创建和延迟销毁。
延迟创建带来了麻烦的一帧的问题。

规则:

ECS不是一种强制性设计原则。如果有些代码不适合ECS,不要强制塞入ECS
update system在主线程上
有些独立的功能是在work thread上的。
比如projectile的模拟,比如nav 路径的重建(和ecs无关)


CLOSING(总结)
-
ECS 是“粘合剂” ECS(Entity Component System)作为一种架构,是用来连接游戏不同子系统(比如输入、动画、物理、技能逻辑、网络等)的一种“中介”结构
-
ECS 可以最小化耦合度 ECS 的好处是能让你写出非常 解耦、模块化、可组合 的代码
-
系统之间互不依赖;
-
网络代码只处理组件的变化;
-
渲染代码、输入处理、模拟逻辑彼此隔离。
-
-
要对你的粘合代码(glue code)加以约束(上面提到的规则)
“粘合代码”指的是把多个子系统“连起来”的中间层代码(例如:网络→ECS、技能系统→特效、输入→行为控制器)
这类代码很容易变成“烂泥山”:
-
逻辑穿插、职责混乱、耦合过高;
-
很难调试或替换某一模块;
-
网络变化时,影响波及全系统。
-
-
网络代码很复杂,所以一定要把它解耦
网络代码尤其难写(tricky),原因包括:
-
要考虑丢包、延迟、乱序、带宽限制;
-
还要兼容回滚、预测、补偿;
-
而且所有这些不能污染游戏核心逻辑
所以最好设计成:
-
网络模块 → 只处理收发消息、解包、缓冲、回放;
-
游戏逻辑模块 → 完全通过命令数据驱动(例如 ApplyCommandFrame())
-

提问时间:
在Component上 有没有实double buffer?
答:没有用到,movement和input是一个ring buffer.你说的这个double buffer也好实现的,我们没用到
你说子弹的命中用的发射者去判断。 如果子弹的生命周期中发射者在不停地变或者离开了怎么办?
答:这是个设计问题,我们没用到,具体问题具体对待
游戏是一定60Hz帧的吗?如果只有30hz的模拟怎么办呢?
答:是的,逻辑帧一定是60的。
如果客户端CPU性能很差,我们可以做一些优化,去避免Death Spiral。
首先 ,本地预测玩家是重点(优先处理),所以本地预测(local player prediction)是最精确、最及时的;这是高优先级的模拟,占用 CPU 比较多是合理的。
### ② 远程角色(其他玩家)使用“预算化模拟”
关键词:Budgeting(设定每帧上限)
“远程角色脚本模拟最多只能花 1.5ms”
解释:
-
远程玩家的状态,客户端通常是插值+预测出来的,不需要超高精度;
-
可以设置每帧最多处理几个远程实体、或者限制脚本调用复杂度;
-
如果时间不够,可以“跳过一些非关键计算”或“延后一帧处理”。
常见策略包括:
-
模拟远程玩家动画、姿态而不是精确动作逻辑;
-
远程技能只表现结果,不模拟路径;
-
Tick 频率降低(例如远程实体每两帧 Tick 一次)。
### ③ Spike Smoothing(峰值平滑)技术
解释:
-
有时帧突然很“重”(比如10个角色同时释放技能),你不能一股脑处理完;
-
可以将部分模拟负载**“分帧处理”**,让逻辑稍微延后,但保持帧率稳定;
-
平滑掉 CPU usage 的 spike,避免掉帧和“death spiral”。
技术方式可能包括:
-
逻辑排队,按预算逐帧执行;
-
异步/分批脚本执行;
-
行为拆解,拆成多个帧去执行(比如动画分段执行、伤害延后一帧触发);
很慢的导弹也做预测了吗?
答:目前没考虑过不做预测,有趣有趣。
量化空间有多精细?那物理引擎在这个精度下会遇到问题吗?有多少游戏逻辑会受到物理引擎的影响?
答:
-
空间量化的精度是大约 1 米 / 1024(≈1mm)
-
也就是一个浮点数表示的位置,会被压缩为大概 10-bit 的离散格。
-
-
他们将“视觉物理引擎”和“游戏物理引擎”分离了:
-
视觉物理(client physics)主要是为了特效、击中效果等视觉表现;
-
游戏模拟物理(simulation physics)才是用于权威状态判断、逻辑碰撞。
-
-
两个物理引擎用的是同一套底层逻辑代码(同一个人写的):
-
所以行为一致性高,且跨平台行为一致(AMD/Intel/Linux等)。我们用了很明确的指令/手法控制浮点行为。
- 客户端和服务器共享关键逻辑代码(或者用统一库编译成 DLL、共享模块)
-
非常稳定,不会因为平台浮点差异出问题。
-
-
他们担心浮点编译器差异,比如 Clang 优化 & 执行乱序
-
但由于用了量化,很多浮点误差问题被规避了;
-
如果有更具体的疑问,可以去第二天某位讲者的分享深聊 IEEE 浮点和一致性问题。
-
你们客户端预测依赖于确定性(determinism),动态导航网格(navmesh)看起来是异步的,那怎么保持一致?
答:整个模拟系统是基于固定时间步长(fixed timestep),所有系统都在统一节奏下运行,是实现一致性的关键基础。
他们不是 100% 的确定性(不像 StarCraft/Halo 那样)
开发者 Igor 做了一个“预测差异调试器”
-
可以看到哪一帧服务器和客户端不一致;
-
可以精确地定位是哪个数学或逻辑步骤出现偏差;
-
通常是“寻找角色脚部的射线(ray)”导致的问题。
关于 Navmesh:
-
Navmesh 的结果在客户端和服务器给相同输入时会一致;
-
但重建 Navmesh 的耗时可能不一致,这并不会导致严重问题;
-
因为大多数玩家移动技能不依赖 Navmesh;
-
即使错预测,也会被服务器矫正回来,不影响最终一致状态;
-
不会为 Navmesh 做完整状态回滚(太大,占 40MB),仅回滚必要内容。

浙公网安备 33010602011771号