前言
众所周知,Epic 在 Unreal Engine 5.1 版本开始以实验特性发布了名为 Iris 的全新复制(Replication)系统。Iris 可以说是对传统复制系统(下称“Legacy”)的全面重做,官方表示这提高了性能、伸缩性、可维护性等。代码量在60,000行左右。可贵的是新的系统保持了对之前各类 game framework 概念的兼容。
目前在互联网上,无论是哪种语言编写的 Iris 相关内容都比较少。(这也是 UE 的特点,有了源码还要什么资料?)但本文不打算对 Iris 的概念和实现等进行阐述,仅对项目集成和性能测试等展开。使用源码来自官方 GitHub: EpicGames/UnrealEngine,版本 5.3.2(注意该版已与部分基于 5.1 的文章描述有出入,迭代较快,细节请自行确认)。
项目集成
本文为了便于测试,以官方的 ThirdPerson
示例为基础,创建一个新工程,名为 IrisDemo
。后面均默认工程名如此。
第一步是将 Iris 插件引入,即添加到 .uproject
的 Plugin
部分。这与其他插件的引入没有区别。
{
"FileVersion": 3,
"EngineAssociation": "{7020973D-4646-B1A9-47F2-B7BCEA27C9E7}",
"Category": "",
"Description": "",
"Modules": [
{
"Name": "IrisDemo",
"Type": "Runtime",
"LoadingPhase": "Default"
}
],
"Plugins": [
{
"Name": "ModelingToolsEditorMode",
"Enabled": true,
"TargetAllowList": [
"Editor"
]
},
{
"Name": "Iris",
"Enabled": true
}
],
"TargetPlatforms": [
"Windows"
]
}
第二步是修改 .Build.cs
,加上 SetupIrisSupport
,构建时才会进行必须的操作。
using UnrealBuildTool;
public class IrisDemo : ModuleRules
{
public IrisDemo(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new []
{
"Core",
"CoreUObject",
"Engine",
"NetCore",
"InputCore",
"EnhancedInput"
});
SetupIrisSupport(Target);
}
}
第三步是向项目的 DefaultEngine.ini
添加 Iris 相关的配置,大致如下:
[/Script/Engine.Engine]
+IrisNetDriverConfigs=(NetDriverDefinition="GameNetDriver", bCanUseIris=true)
[/Script/Engine.NetDriver]
+ChannelDefinitions=(ChannelName=DataStream, ClassName=/Script/Engine.DataStreamChannel, StaticChannelIndex=2, bTickOnCreate=true, bServerOpen=true, bClientOpen=true, bInitialServer=true, bInitialClient=true)
[SystemSettings]
net.Iris.UseIrisReplication=1
net.IsPushModelEnabled=1
[/Script/IrisCore.ObjectReplicationBridgeConfig]
DefaultSpatialFilterName=Spatial
; Pawns can be spatially filtered
+FilterConfigs=(ClassName=/Script/Engine.Pawn, DynamicFilterName=Spatial)
+FilterConfigs=(ClassName=/Script/EntityActor.SimObject, DynamicFilterName=None)
+DeltaCompressionConfigs=(ClassName=/Script/Engine.Pawn)
+DeltaCompressionConfigs=(ClassName=/Script/Engine.PlayerState)
[/Script/IrisCore.NetObjectGridFilterConfig]
ViewPosRelevancyFrameCount=600
这里对各个部分简单解释:
- Engine:把
GameNetDriver
设置为可使用 Iris。 - NetDriver:新增一个默认的 Channel 名为
DataStream
,并设置静态 Channel Index 为 2(不要变,后面还用到!)。我们知道 0 号 Channel 是控制消息,1 号 Channel 是语音。Iris 本身不关心网络部分,只需要正确实现它的DataStreamManager
;目前 Epic 并没有给出一个全新优化后的网络底层,所以仍然使用了传统的网络通路,使用一个 Channel 传输所有的 Iris 数据。 - SystemSettings:Iris 的开关。虽然用命令行参数(
-UseIrisReplication
)也可指定,不过这里建议使用配置文件的方式,后续对比测试来回切换也比较方便。如果以命令行参数启用,则一定要确保 client 和 server 的开启状况一致,否则自然是协议不同无法连接的。 - ObjectReplicationBridgeConfig:官方给的一些 Iris 配置,包括官方已实现的 filter 和 delta 压缩等。这里主要是默认把对象路由到 spatial filter(类似于 Replication Graph 的 GridSpatialization2D)上。
- NetObjectGridFilterConfig:这里是上面所说的 spatial filter 的配置。根据源代码的注释可知,这里的配置值用于指定“每多少帧必定复制一次”,是为了处理在 cell 边界时可能发生的裁剪问题。默认配置为 2(官方对自己这么没信心吗?),意思是即使已经超过了指定距离,仍然每 2 帧强制复制一次——这样的话测试结果根本就不对。因此先改为一个较大的值,等官方后续慢慢改好了去掉这个选项吧。
总的来看,即便是当前阶段,接入 Iris 也不算太麻烦,并且允许无痛地在 Iris 和 Legacy 之间切换(前提是没有魔改 rep graph)。说是“开箱即用”,问题也不大。
构造测试环境
模拟连接
之前官方在 NetcodeUnitTest
插件里提供了 UMinimalClient
机制,相信已经有很多人使用过。原理是模拟真实客户端连接到服务器,但是对于收到的 bunch 选择性处理(绝大多数都不做操作)。使用者可以继承拓展,实现对特定 bunch 或 RPC 等进行应答等。使用这样的机制,可以只开启一个客户端,但是创建上百个连接到服务器。
兼容 Iris
可惜的是,目前并不支持连接到基于 Iris 的服务器。实测发现需要进行如下改造。部分方式可能较为粗暴。
步骤一:将UMinimalClient::WriteControlLogin
覆写。
void UIrisDemoMinimalClient::WriteControlLogin(FOutBunch* ControlChanBunch)
{
uint8 MessageType = NMT_Login;
FString BlankStr = TEXT("");
FString OnlinePlatformName = TEXT("Dud");
uint8 EncType = 0;
FString ConnectURL = DefaultClientConnectURL + URLOptions;
UNIT_LOG(, TEXT("Sending NMT_Login with parameters: ClientResponse: %s, ConnectURL: %s, UID: %s, OnlinePlatformName: %s"), *BlankStr, *ConnectURL, *JoinUID, *OnlinePlatformName);
*ControlChanBunch << MessageType;
*ControlChanBunch << BlankStr;
*ControlChanBunch << ConnectURL;
*ControlChanBunch << EncType;
*ControlChanBunch << JoinUID;
*ControlChanBunch << OnlinePlatformName;
}
可能需要对生成 ConnectURL
的部分进行自己的修改。修改的原因是,基类的对应函数,生成该 URL 时使用了 UUnitTest::UnitEnv
,而该 static
变量是个空指针。
步骤二:屏蔽 UUnitTestManager::Serialize
。即整个函数全部注释或进去就 return
。原因是此处会 crash 且堆栈较为诡异,考虑到不是关键路径,暂时没有深究。有大师更加了解的也欢迎讨论。
步骤三:修改 UMinimalClient::SendInitialJoin
。
if (UNetDriver* NetDriver = UnitNetDriver.Get())
{
// LocalNetworkFeatures = NetDriver->GetNetworkRuntimeFeatures();
LocalNetworkFeatures = EEngineNetworkRuntimeFeatures::None;
if (UE::Net::ShouldUseIrisReplication())
{
LocalNetworkFeatures |= EEngineNetworkRuntimeFeatures::IrisEnabled;
}
}
Minimal Client 完全没有考虑过 Iris 的问题,所以此处也是写死 network features,导致连接时因 feature 不可用失败。这里在开启 Iris 时将对应 feature 标记上去。
步骤四:修改 UUnitTestChannel::Init
。
void UUnitTestChannel::Init(UNetConnection* InConnection, int32 InChIndex, EChannelCreateFlags CreateFlags)
{
// If the channel type is still default, assume this is a control channel (since that is the only time ChType should be default)
if (ChName == NAME_None)
{
// if (InChIndex != 0)
// {
// UE_LOG(LogUnitTest, Warning, TEXT("Unit test channel type was NAME_None, for non-control channel"));
// }
//
// ChName = NAME_Control;
switch (InChIndex)
{
case 2:
ChName = FName(TEXT("DataStream"));
break;
default:
ChName = NAME_Control;
break;
}
UE_LOG(LogUnitTest, Warning,
TEXT("InChIndex=%d, channel type was NAME_None, renamed to %s"), InChIndex, *ChName.ToString());
}
Super::Init(InConnection, InChIndex, CreateFlags);
}
一段对于 Channel 的默认改名逻辑。之前说过 0 号通道是控制通道,而我们在配置文件里把 2 号通道分配给了 Iris 的 DataStream。这里加上对应逻辑,否则进入该函数后 2 号通道也被改名为 Control,导致 channel close。
步骤五:修改 UMinimalClient::ConnectMinimalClient
。
UnitNetDriver->ChannelDefinitionMap[FName(TEXT("DataStream"))].ChannelClass = UUnitTestChannel::StaticClass();
仍然是针对 DataStream 的问题。为了使数据包不被客户端逻辑所处理,对应的 channel 都要改为 UUnitTestChannel
,来拦截其中逻辑。把 DataStream 也改过去。
创建假客户端
完成上述修改后,应该能够正常跑起来了。要做的事情就是创建大量的假玩家。方法可以参考 UClientUnitTest::ConnectMinimalClient
。
模拟业务场景
属性复制工作负载
为了测试方便,这里新建一个 actor component 专门模拟属性复制。之后需要测试的 actor(如 character)挂上这个组件就行。注意要按 Push Model 的规范写——一是目前的 Iris 依赖 Push Model 来标记哪些 object 发生了变化,二是只有 full push model 情况下的 Legacy 和 Iris 相比才比较公平。
角色分布
还是用普通的方式:GameMode 上覆写 FindPlayerStart_Implementation
。我这里是允许在蓝图里指定中心点和正方形的分布范围。
非角色 Actor
模拟的是可交互的物品等,如“吃鸡”类游戏里的可拾取物。这里基于 AActor
派生一个类,挂静态网格体和前面说过的模拟属性复制的组件。
测试环境
运行平台:
- CPU: AMD Ryzen 5975WX 32C64T @ 3.6 GHz
- RAM: 64 GB × 2 @ 3200 MHz
- GPU: NVIDIA GeForce RTX 4080
- OS: Windows 11
Unreal 的构建目标为 Development Editor。测试启动 1 个 DS,Client 和 DS 均在本机运行;对且仅对 DS 开启性能 trace,后续使用 Unreal Insights 分析。
测试和分析
对一些典型情况对比测试。对 Legacy 开启 full Push Model 和 Replication Graph,且 Rep Graph 关闭 trick(dynamic node 底层不使用 freq bucket);Iris 的 Spatial filter 的强制复制间隔设置为 600 帧。
每组均是连接 100 个客户端。
组一:纯角色,密集
分布范围是边长 200 米的正方形。
(avg) | Legacy | Legacy + RG | Iris |
---|---|---|---|
GameEngine Tick | 64.79 ms | 68.65 ms | 14.07 ms |
NetDriver TickFlush | 58.05 ms | 62.70 ms | 8.68 ms |
Per Conn Ops | 450 μs | 602 μs | 50 μs |
注:Per Conn Ops 指的是对于单个连接的集中操作。Legacy:Process Prioritized Actors Time;Legacy + RG:Process Gathered Lists;Iris:NetConnection Tick。对于单连接的操作还有很多,此处统计数据仅来源如上。后续也如此。
由于角色的 cull distance 是 250 米,所有角色之间都需要互相复制。因此 rep graph 对于视野相关性的优化在此处不会起到帮助,反而有额外开销(猜测 grid 维护以及排序打了大量 trace 影响较大),成绩劣于原始版本。
总体 Legacy 和 Iris 相比,也是劣势极为明显。毕竟 Iris 花大功夫在各种数据的复用上,优化效果最好的场景就是这种极端聚集。下面仔细看看:
这种密集场景下,能看到我们熟悉的耗时结构:90%的时间花费在网络复制上。业务场景越复杂,越难以优化,且给到中间业务逻辑 tick 的时间就更少。没有任何业务逻辑的 demo 在百人同屏的情况下只能跑到 13 fps 左右。
而单个连接的处理,又能够看到基于 actor 粒度的复制,其中的每个 object 走“比较—序列化—发送”流程。
进一步放大,可以看到 Legacy 的这个步骤确实耗时比较大,复制一个角色就要 4.5 μs,怪不得平均的每连接操作耗时差距有 9 倍。
而 Iris 这边看起来就“健康”得多,因网络复制本身耗时大大降低,在一帧里的占比也没有那么夸张了。
而网络操作部分,前期准备工作(pre send update)和对于每个连接的操作可以说是对半开。虽然前期准备工作耗时比 Legacy 高得多,但正是有了这些操作生产出的可复用信息,才能使 Iris 整体性能得到提升。复制同样多的信息,Iris 这边到 connection 时只需要依据精确到 property 的标记(ChangeMask)打序列化并发送出去即可。
组二:纯角色,稀疏
分布范围是边长 8000 米的正方形,这也是 PUBG 地图的大小。此时均匀分布的角色互相之间都超出裁剪距离,只有自己还需要复制。
(avg) | Legacy | Legacy + RG | Iris |
---|---|---|---|
GameEngine Tick | 11.79 ms | 13.66 ms | 11.91 ms |
NetDriver TickFlush | 7.16 ms | 8.81 ms | 6.70 ms |
Per Conn Ops | 48.3 μs | 64.0 μs | 24.4 μs |
这次因为没有重复复制发送的数据,少了“无用功”,二者差别就不是那么大了。
Replication Graph 开启时,每个连接下面的 actor 排序都会进行事无巨细的 trace 记录,怀疑因为这个问题导致结果里性能差于不开 Rep Graph(确实 per conn 差异悬殊)。
看到 Iris 这边。Pre Send Update 阶段和之前的密集情况耗时基本相同;单连接对应耗时有所降低。基于此可知 Pre Send 阶段是固定开销,不随场景而变。
现在这个只有角色的稀疏场景,没有拉开差距,Iris 以微弱优势胜出。但 Iris 对比 Legacy,有没有什么情况会变得更差呢?可能需要再来看看稀疏场景下,除角色外还有别的 actor 的情况。
组三:角色与少道具,稀疏
分布范围是边长 8000 米的正方形。每个角色周围生成10个宝箱(即前文里挂载了属性复制测试组件的静态网格体)。因 actor 数量显著增多,从本组开始 Legacy 不使用非 Replication Graph 的版本。
(avg) | Legacy + RG | Iris |
---|---|---|
GameEngine Tick | 26.26 ms | 21.97 ms |
NetDriver TickFlush | 20.63 ms | 12.84 ms |
Per Conn Ops | 179.0 μs | 85.9 μs |
Iris 以小幅优势胜出。这个场景下,Pre Send 耗时已经大于实际 send,不过二者还是接近一半一半。
组四:角色与多道具,稀疏
分布范围是边长 8000 米的正方形。每个角色周围生成20个宝箱。
(avg) | Legacy + RG | Iris |
---|---|---|
GameEngine Tick | 38.13 ms | 32.01 ms |
NetDriver TickFlush | 32.12 ms | 24.28 ms |
Per Conn Ops | 288.0 μs | 95.1 μs |
这一组的分析,需要和上一组对比,看 actor 数量更多时,趋势是怎样的。可以看到 Legacy 的单连接复制耗时多出了 60%,而 Iris 只多出 11%——这说明即使是不可复用的部分,Legacy 的 “对比-序列化-发送” 流程也是太慢了。所以 Iris 流程中虽然存在一个固定开销 Pre Send,但整体是全新架构,整体来说仍是不落下风。
总结
为直观感受 UE 5.1 引入的实验特性 Iris Replication 的实际效果,我们使用一个全新实验工程,完成了 Iris 的引入、单机测试环境的改造和开发,并针对几种典型场景开展了对比测试。
从性能测试结果来看,Iris 在高度复用的密集场景下效果确实非常好,耗时几乎是仅占 Legacy 10%;而复用度较低的情况下,也没有发生负优化,大概可以在各种场景下使用。Legacy 也彻底体现出了 Connection-Actor 粗粒度下 Compare-Serialize-Send 模型的缺点,积重难返,这就不难知道为何 Iris 不再像 Rep Graph 或 Push Model 那样给引擎打补丁,而是选择重新开发一套真正高内聚低耦合的全新的复制系统。
然而,当前 UE 5.3 版本的 Iris 作为实验特性,还有几个大问题:
- 官方没有给出全新的网络底层实现(问题不大,本来这一块自行优化才是更好的方案)
- 虽然已经比较全面地考虑了各个引擎特性的协作,但官方没有做好全面兼容,比如上面提到的
NetcodeUnitTest
- 代码里有大量的 TODO,不完善,摇摇欲坠
- 还没有在大型上线项目(堡垒之夜?)得到过稳定性验证,官方也不认可用户现在就在生产环境使用
因此说全面替代还为时尚早,UE 6 才从 Experimental 里移出来都有可能。Legacy 和 Iris 二者并行是目光所及内最可能出现的情况。不妨再等等 Epic 的改进,在这期间也可以积极地尝试集成(如果是刚启动的新项目)。毕竟从实际情况来看 Iris 新架构带来的提升是很诱人的。