使用DOTS制作一款第三人称僵尸射击游戏
本文,瑞典游戏工作室Far North Entertainment将分享在传统的Unity项目中使用DOTS的经验。
我们正在使用面向数据技术栈DOTS重构Unity的核心基础。许多游戏工作室在使用C# Job System、实体组件系统ECS和Burst Compiler后,都无一例外地感受到明显的性能提升,其中就包含了瑞典游戏工作室Far North Entertainment。
在Unite Copenhagen大会上,我们与Far North Entertainment工作室的成员进行深入交流,了解他们如何在传统的Unity项目中应用DOTS功能。
Far North Entertainment
瑞典的游戏工作室Far North Entertainment是由5位来自工程研究专业的好友共同创建。自2018年初在Gear VR平台发布《Down to Dungeon》游戏之后,该公司一直致力开发一款末日僵尸生存游戏。
这款末日僵尸生存游戏的独特之处在于僵尸的数量,开发团队希望实现成千上万个饥渴的僵尸追逐玩家的效果。然而在构建原型时,他们遇到了许多性能方面的问题。
开发中主要的瓶颈在于对庞大数量僵尸的进行生成、销毁、更新和添加动画,虽然开发团队尝试了对象池和动画实例化等方法,但效果仍不显著。因此,技术总监Anders Eriksson将目光投向DOTS,从面向对象(Object-oriented)设计转为面向数据(Data-oriented)设计。
Anders Eriksson表示:促成我们思维模式发生改变的关键是停止考虑对象和对象层级,转为思考数据是如何变换和访问的。这意味着代码不必围绕具体事物来编写,不用处理过去最常见的情况。
对于同样在试着转换思维模式的开发者,Anders Eriksson的建议是:先弄清楚要解决的问题和解决方案的相关数据。是否会对相同数据集执行相同的处理过程?可以把多少关联数据打包到CPU缓存行中?如果想转换现有代码的话,那么要确定会给缓存行加入的垃圾数据量。能否将运算过程分配到多个线程上,能否利用SIMD指令?
在进一步学习后,开发团队了解到Unity组件系统的实体只是组件流中的查找ID。组件只是数据,而系统包含了所有逻辑,系统会使用称为“Archetypes(原型)”的特别组件标识来过滤实体。
Anders Eriksson表示:我们将ECS看作SQL数据库可以帮助我们更好地理解它。每个Archetype原型是一张表格,每行代表一个组件,每列代表一个独特的实体。我们可以使用系统查询这些Archetype原型表,在实体上执行操作。
开始使用DOTS
为了更好地理解,Anders Eriksson研究了实体组件系统的文档和ECS示例项目,以及Unity与Nordeus合作制作的示例项目。
此外,关于面向数据设计的学习材料也对团队有很大的帮助。CppCon 2014大会上Mike Acton关于面向数据设计的演讲开阔了他们的眼界,让开发团队了解了这种编程方式。
Far North Entertainment的开发团队在博客上发表了许多学习心得,今年9月,他们在Unite Copenhagen大会上进行演讲,介绍了转换到面向数据思维的经验。
本文的内容将以这次演讲作为基础,并且详细地讲解该团队应用ECS、C# Job System和Burst Compiler的具体方法。
排列僵尸数据
Anders Eriksson表示:我们面临的主要问题是客户端的转换信息插入,以及对上千个实体的转向信息。
开发团队最初使用面向对象的方法,编写了ZombieView脚本的抽象,它继承了更为常用的EntityView父类。EntityView是附加在游戏对象的MonoBehaviour,它会用作游戏模型的可视化展示。每个ZombieView脚本会在Update函数中处理相应的信息转换和朝向信息插入。
这种方法似乎挺不错,但问题是每个实体会在内存中占用随机的位置。这意味着,在访问数千个实体时,CPU需要从内存中逐个获取实体数据,这个过程非常耗时。
如果将数据存在整齐的连续内存块中,CPU则可以同时缓存所有实体数据。现今大多数CPU在每个运行周期中可以从缓存获取128比特或256比特的数据量。
开发团队决定改用DOTS系统生成敌人,希望借此解决客户端的性能瓶颈问题。首先,要转换的是ZombieView脚本中的Update函数,团队确定了哪些代码要划分到不同的系统中,以及哪些是必要的数据。
游戏世界是一个2D网格,最首要的是对位置和朝向进行插值处理。僵尸的前进方向由两个浮点值表示,最后的组件是一个目标位置组件,它会跟踪敌人的服务器位置。
[Serializable]
public struct PositionData2D : IComponentData
{
public float2 Position;
}
[Serializable]
public struct HeadingData2D : IComponentData
{
public float2 Heading;
}
[Serializable]
public struct TargetPositionData : IComponentData
{
public float2 TargetPosition;
}
然后是为敌人创建Archetype原型。Archetype原型是一组属于特定实体的组件集,也可以说是一个组件标识。在项目中,由于敌人需要使用更多的组件,而且部分组件需要游戏对象的引用,因此开发团队使用了预制件来定义Archetype原型。
他们的方法是:在ComponentDataProxy中包装组件数据,ComponentDataProxy会把数据转化为可附加到预制件的MonoBehaviour。当调用EntityManager执行实例化操作,并传入预制件时,系统会创建带有预制件上所有组件数据的实体。所有组件数据都存储在称为“ArchetypeChunks(原型数据块)”的16kb大小的数据块中。
下图展示了原型数据块中的组件数据流的组织方式。
Anders Eriksson解释说:原型数据块的一个主要优点是,系统不必在创建新实体时处理新的堆分配,因为内存已预先分配。因此在创建实体时,系统会直接在原型数据块的组件流末尾处写入数据。
只有当创建的实体数据不符合数据块类型时,系统才会需要执行额外的堆分配。在这种情况下,系统会创建新的16kb原型数据块来进行分配,如果有相同类型的空原型数据块,则会将其重新利用。随后,系统会将新实体的数据写入到新数据块的组件流中。
对僵尸进行多线程处理
现在数据被紧凑地打包,并在内存中以对缓存友好的方式布局好,开发团队可以轻易利用C# Job System在多个CPU内核上并行运行代码。
下一步是创建可以在所有包含PositionData2D、HeadingData2D和TargetPositonData组件的原型数据块中过滤掉所有实体的系统。
为此,Anders Eriksson及其团队编写了JobComponentSystem脚本,在OnCreate函数上构建查询功能。
代码如下所示:
private EntityQuery m_Group;
protected override void OnCreate()
{
base.OnCreate();
var query = new EntityQueryDesc
{
All = new []
{
ComponentType.ReadWrite<PositionData2D>(),
ComponentType.ReadWrite<HeadingData2D>(),
ComponentType.ReadOnly<TargetPositionData>()
},
};
m_Group = GetEntityQuery(query);
}
这些代码会执行一次查询,过滤掉所有包含位置、朝向和目标的实体。然后,开发团队通过C# Job System在每帧上调度任务,将运算过程分配到多个工作线程上。
Andres Eriksson表示:C# Job System的优点在于,C# Job System也在Unity的源码中使用,因此我们不必担心出现多个线程在执行过程中同时占用相同CPU内核,产生互相阻碍各自执行的性能问题。
由于成千上万的敌人意味着在运行时会有大量的原型数据块要匹配查询过程,所以开发团队选择使用IJobChunk,它可以在不同的工作线程上正确地分配各个数据块。
在每帧上,名称为“UpdatePositionAndHeadingJob”的新作业会处理游戏中敌人的位置和转向插值。
调度作业的代码如下所示:
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var positionDataType = GetArchetypeChunkComponentType<PositionData2D>();
var headingDataType = GetArchetypeChunkComponentType<HeadingData2D>();
var targetPositionDataType = GetArchetypeChunkComponentType<TargetPositionData>(true);
var updatePosAndHeadingJob = new UpdatePositionAndHeadingJob
{
PositionDataType = positionDataType,
HeadingDataType = headingDataType,
TargetPositionDataType = targetPositionDataType,
DeltaTime = Time.deltaTime,
RotationLerpSpeed = 2.0f,
MovementLerpSpeed = 4.0f,
};
return updatePosAndHeadingJob.Schedule(m_Group, inputDeps);
作业的声明如下:
public struct UpdatePositionAndHeadingJob : IJobChunk
{
public ArchetypeChunkComponentType<PositionData2D> PositionDataType;
public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType;
[ReadOnly]
public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType;
[ReadOnly] public float DeltaTime;
[ReadOnly] public float RotationLerpSpeed;
[ReadOnly] public float MovementLerpSpeed;
当一个工作线程从队列中抽调一个作业时,它会调用该作业的执行核心。
下面是执行核心的代码:
public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
var chunkPositionData = chunk.GetNativeArray(PositionDataType);
var chunkHeadingData = chunk.GetNativeArray(HeadingDataType);
var chunkTargetPositionData = chunk.GetNativeArray(TargetPositionDataType);
for (int i = 0; i < chunk.Count; i++)
{
var target = chunkTargetPositionData[i];
var positionData = chunkPositionData[i];
var headingData = chunkHeadingData[i];
float2 toTarget = target.TargetPosition - positionData.Position;
float distance = math.length(toTarget);
headingData.Heading = math.select(
headingData.Heading,
math.lerp(headingData.Heading,
math.normalize(toTarget),
math.mul(DeltaTime, RotationLerpSpeed)),
distance > 0.008
);
positionData.Position = math.select(
target.TargetPosition,
math.lerp(
positionData.Position,
target.TargetPosition,
math.mul(DeltaTime, MovementLerpSpeed)),
distance <= 1
);
chunkPositionData[i] = positionData;
chunkHeadingData[i] = headingData;
}
}
Anders Eriksson指出:你可能注意到我们使用了Select函数而不是Branch函数,这样做的原因是避免所谓的分支误预测。
Select函数会在两种表达式中选择匹配当前条件的一种,如果表达式并不需要很多的运算量,我建议使用Select,因为它更轻便,不必等待CPU从分支误预测问题中恢复过来。
使用Burst Compiler提升性能
对于敌人位置和朝向的插值,完成DOTS转换的最后一步是启用Burst Compiler。
由于已经在连续数组中排列好数据,又使用了Unity的全新Mathematics库,因此只需给作业添加上BurstCompile属性便可启用该功能。
[BurstCompile]
public struct UpdatePositionAndHeadingJob : IJobChunk
{
public ArchetypeChunkComponentType<PositionData2D> PositionDataType;
public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType;
[ReadOnly]
public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType;
[ReadOnly] public float DeltaTime;
[ReadOnly] public float RotationLerpSpeed;
[ReadOnly] public float MovementLerpSpeed;
Burst Compiler可以提供单指令多数据流(SIMD),机器指令可以对多个输入数据集进行操作,通过一个指令产生多个输出数据集。这样就可以在128比特大小的缓存中加入更多正确的数据。
通过结合Burst Compiler、易于缓存的数据布局和C# Job System,开发团队取得了很大的速度提升效果。
下面是性能对比图表展示了在每个转换步骤后速度的变化。
结果显示:对于客户端上僵尸位置和朝向的插值过程上,开发团队完全摆脱了此前遇到的瓶颈。数据的排布方式会更便于缓存,而且缓存行上只有相关的数据。所有的CPU内核都能够投入工作,而Burst Compiler的输出数据都是带有SIMD指令的高度优化机器代码。
DOTS使用技巧
下面分享Far North Entertainment开发团队对DOTS的一些使用技巧:
使用数据流的模式进行思考,因为在ECS中,实体只是用于并行组件数据流的查询索引。
将ECS看作关系型数据库,Archetype原型是表格,组件是行,而实体是表格内的索引(列)。
将数据组织到连续的数组中,从而利用好CPU缓存和硬件预取器。
不再以创建对象层级作为第一件事,在弄清楚真正要解决的问题前,制定通用的解决方案。
要考虑垃圾回收过程。对于性能资源紧张的位置,要避免进行过多的堆分配,并利用好Unity的Native容器。但要注意的是,此时需要手动进行清理过程。
了解抽象部分的开销,注意虚拟函数的调用开销。
通过使用C# Job System,利用好所有的CPU内核。
熟悉面向的硬件。Burst Compiler是否生成了SIMD指令?此时要使用Burst Inspector进行分析。
避免浪费缓存行。在将数据打包为UDP数据包时,要考虑如何将数据打包存到缓存行上。
针对已经在制作阶段的项目,Anders Eriksson的建议是:找出游戏中出现性能问题的具体位置,看看能否在这些位置使用DOTS。开发者没必要转换整个代码库。
结语
Anders Eriksson总结说:Unite大会上发布的DOTS动画功能、Unity Physics和Live Link让我们感到非常兴奋,我们会在游戏中更多地利用DOTS功能,希望可以将更多的游戏对象转换为ECS实体,而且Unity看起来在这个目标上取得了很好的进展。
下载Unity Connect APP,请点击此处。 观看更多Unity官方精彩视频,请关注“Unity官方”B站账户。
你可以访问Unity答疑专区留下你的问题,Unity社区和官方团队帮你解答:
Connect.unity.com/g/discussion
推荐阅读
使用Unity的ECS和Job System实现流体模拟效果
Unity UI Profiling:你怎么敢破坏我的批处理?
官方活动
「Unity X 创想家计划」是针对中国地区9-15岁青少年的编程教育计划,火热报名中~~[了解详情...]
喜欢本文,请点“在看”