Unity DOTS 学习笔记(一)——什么是DOTS

前言

接触Unity的DOTS其实已经有差不多两年时间了,当年刚开始研究的时候甚至还没有DOTS这个说法,期间其ECS库的API各种变化,不过到目前来看基本上稳定了,所以决定今天把自己的理解整理出来,便于回顾查阅。。。

什么是DOTS

DOTS全称Data-Oriented Technology Stack,官方中文名叫多线程式数据导向型技术堆栈(来源于官网),顾名思义这是个“技术栈”,这里面包含了多个技术:

  1. Entity Component System(ECS)
  2. C# Job System
  3. Burst Compiler
  4. And more(Physics, NetCode, Tiny…)

需要我们编写业务逻辑代码的部分都在第一项”ECS”里面,所以之后的介绍大多都会围绕如何(以面向数据的思想)书写基于ECS的代码。

Entity Component System(ECS)

即“实体组件系统”,有关这个最有名的一篇科普大概就是暴雪在GDC2017上发表的那一次演讲了。

我当时看到这个的宣传,就隐约感觉这个很适合模拟经营类的游戏——这种场景中有着大量实体并且这些实体有着差不多的逻辑的类型。刚好自己特别喜欢这类游戏,就打算拿这个新技术来练练手做个完整的游戏出来(当然游戏的开发已经正在进行中,虽说进度很慢,一年多连原型还都算不上😅)。

就像名字里描述的那样,ECS分为三个部分,实体、组件、系统。其中组件存储所有数据,实体是多种组件的集合,系统负责处理组件数据。

跟传统的Unity组件不同的是,ECS中的组件只保存数据而没有行为逻辑,游戏中所有的数据都存储在各个组件里面。

系统则只执行逻辑,读写组件里存储的数据,自身没有状态,我觉得可以把系统理解成一个静态方法,每帧Update的时候以引用的形式传给这个静态方法一些对象数据,这个方法就只根据传入的数据来执行逻辑,系统自身是无状态的,即不存储任何数据。

多个组件的组合成为一个“实体”,这个实体是个逻辑上的概念,实际上一个实体就是一个索引ID,用于实体管理器来索引哪些组件对应哪个实体。

这样的好处在于,一是完全把数据和逻辑解耦,二是把各个不相干的逻辑也完全解耦(每个系统只关心需要的组件,而不关心这个组件所属哪个实体,举个栗子,”MovementSystem”只负责计算更新移动相关,而不用去管是主角在移动还是怪物在移动),三是提升CPU的缓存命中率,从而大幅提升逻辑代码的运行效率。

为了使用这个ECS框架,你需要在Package Manager中添加Entities包,这个包还有一大坨子依赖(SIMD数学库,基于非托管内存的原生容器,Burst编译器等),都会自动安装。

fig1

C# Job System

这个东西其实已经发布了有几年了,一句话描述就是让用户轻松写出充分利用多核CPU的的代码,而不必关心多线程编程中常见的问题(读写屏障,竞态条件等)。

众所周知现在电脑上的CPU核心数量越来越多,3990X甚至已经已经有了64核心128线程,为了避免“一核有难N核围观”的尴尬场面,我们需要尽可能地把任务分配到多个核心,让更多的核心工作起来。

于是Unity发布了Job System,这个东西是内置在引擎中的,无需添加额外的包,同时Unity编辑器也在尽可能地Job化了。Job System的原理是,用户定义一个Job,在主线程由调度器进行调度(Schedule),除了主线程之外,Unity会生成几个Worker线程,其数量和CPU的逻辑处理器数量(线程数)有关,比如我这里使用的是i7 7700CPU,它拥有4核心8线程,于是Worker线程的数量就是8-1=7个(在Profiler中可以看到)。回到前面,Job在主线程被调度后会被分配到各个Worker线程来执行,以实现对数据的并行处理。

Burst Compiler

DOTS怎么用

Job System

Job System是个独立的功能,跟ECS之间并不互相依赖,只利用Job System我们一样可以实现多线程逻辑,像是这样:

1
2
3
4
5
6
7
8
9
struct Job : IJobParallelFor
{
public NativeArray<int> Input;
public NativeArray<long> Output;
public void Execute(int index)
{
Output[index] = Input[index] * Input[index];
}
}

首先定义一个Job,实现了IJobParallelFor接口,这是众多IJob系列中的其中一个,常用的有三种

  1. IJob - 最普通的Job,执行的时候会随机分配一个Worker线程来执行,并不是并行处理的
  2. IJobFor - 可以对一个数组进行处理,可以选择分配到单一的Worker线程(使用Schedule()方法调度),也可以分配到多个Worker线程并行处理(使用ScheduleParallel()方法调度)
  3. IJobParallelFor - 同样是对一个数组进行处理,只能以并行的方式执行

我们这里为了展示Job的性能优势,选用的是IJobParallelFor,上边代码里的Job执行的任务很简单,给定一组数字,计算它们的平方。定义好Job的行为之后,就是准备数据,以及对这个Job进行调度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const int length = 5;
// 新建Job
var job = new Job
{
Input = new NativeArray<int>(length, Allocator.TempJob),
Output = new NativeArray<long>(length, Allocator.TempJob)
};
// 准备数据
for (var i = 0; i < length; ++i)
{
job.Input[i] = i;
}
// 调度
job.Schedule(length, 64);
// 立即在当前线程(主线程)阻塞等待job执行完毕
job.Complete();
// 查看结果
foreach (var item in job.Output) Debug.Log(item);
// NativeArray容器分配的内存是非托管内存,不参与GC,所以别忘记手动释放他们:)
job.Input.Dispose();
job.Output.Dispose();

好的,我们执行一波这段代码,顺利的话就能在Console面板中看到输出结果了

fig2

我们这里使用的是NativeArray作为容器,这个容器分配的内存不参与GC,所以必须手动释放。当然,如果你忘记手动释放的话也不用太过担心,Unity会在调试阶段自动检查,如果超过4帧(分配方式为Allocator.TempJob)仍未释放,Unity会输出这样的错误信息:

fig3

只处理5个数据只是为了方便查看输出Log,接下来我们加大力度,直接改成8000,一次处理这么多总该能消耗点时间了吧~

1
const int length = 8000;

然后打开Profiler的Deep Profile,再次运行这段代码,于是可以看到,job.Complete()方法花了28毫秒,同时发现主线程上执行了896次Job.Execute(),也就是主线程上处理了896次计算,另外7104次都是在Worker线程上执行的。

fig4

然后切换到Profiler的Timeline视图,往下翻然后适当的缩放,就能找到其余这7000多次计算都已经被均匀地安排到了另外7个Worker线程去执行了。

fig5

Burst

然后我们加一行代码:

1
2
3
4
5
[BurstCompile]
struct Job : IJobParallelFor
{
// 此处省略...
}

对,然后就可以了,再执行一次试试:

fig6

可以看到,时间从28毫秒缩短到了。。0.08毫秒?!看来,在这种非常简单的计算逻辑中,只是简单地启用Burst编译,执行速度可以增加350倍!这就是Unity所谓的“免费的性能提升”了。

Burst的限制

Burst编译器的威力已经感受到了,但这有个前提,我们上边的样例Job中,逻辑是非常简单的,当Job中的逻辑变得复杂起来时,速度加成就没有这么明显了,当然了,提升是肯定的。关键在于,如果你的Job中使用了C#托管对象,这个Job就不支持使用Burst来编译了。事实上,Burst只支持有限的数据类型(不支持托管对象)和有限的语言特性(不支持try-catch)。当然随着版本的更新,支持的类型和特性也会会慢慢变多的。

ECS

ECS就比较复杂了,这里先介绍到这,有关细节的使用以及编码相关的东西就慢慢随缘更新吧~