前言
DOTS未来的潜力还是挺多的,目前已经陆续有项目局部投入该技术,追求硬件到软件的极致性能。
主要是记录下学习unity dots技术的过程吧。
学习DOTS的前置
什么是DOTS?
DOTS全称Data Oriented Tech Stack,面向数据的技术堆栈。
是由Unity的下面几个技术栈组成
1.Entities(ECS,Entity-Component Data-System)
2.JobSytem(多线程作业调度)
3.BurstCompiler(IR代码优化编译器)
4.Unity Collections(Native Container,非托管内存集合)
5.Unity Mathematiecs(SIMD,单指令多数据数学库)
6.Unity Physics (Dots版本的物理库)
ECS的相关概念
在进行理解ECS之前,需要理解CPU中的Data Layout。
比方说CPU在执行处理指令时是需要将内存里的数据拷贝到CPU本地的Cahce里面的。结构如下:
当CPU执行指令要访问数据的时候,首先会在Cache里面寻找这个数据,如果没有找到这个时候就产生了一次Cache Miss。
接下来它就要到内存里面拷贝一个数据到 CPU 的 Cache 里面,但是这个步骤是非常慢的。当从内存拷贝到 CPU 的 Cache 之后,再从 Cache 里访问这个数据就会非常快。后面再去访问这一条 Cache line 中的数据都会是非常快的。
如果后面继续访问数据,发现到了上次拷贝过的数据没有覆盖的另一条 Cache line,就又会发生一次 Cache miss,又会比较慢,需要再去内存拷贝数据。
ECS是Enity Component System的缩写,是一种面向数据编程的设计思想,Enity只存放世界场景中的Id(唯一标识),数据放在Component里面,System处理这些数据的逻辑。
值得注意的是,ECS和传统的老GameObject方式不一样。
因为我们在使用传统的GameObject(面向对象)的方式进行编程时候,代码访问内存的地址是随机的,所以产生的内存是零散的,这个时候就会造成CPU大量的Cache Miss,这样会造成大量的性能浪费。
下图为使用GameObject的方式编程时,内存的布局分配。
所以之后引用了ECS的概念,主要是为了使我们的数据结构对CPU友好,使用ECS的数据结构如下图
那么这里会有个问题,比方上面的图图片,对应的Enity的下面,不同的方块颜色为不同的组件,上面有4个Enity为7个组件,5个enity有5个组件,那么这样就会产生了不同的Archetype。带入,相同的组件数但是组件不相同也是不同的Archetype。当然不同 Archetype 之间可能会共享一些 Component 数据结构,可以去利用这一点来加速计算。
有了 DOTS 的这种结构之后,实际去执行代码的时候会有一个操作叫做 Query,来 Query 需要的数据对象进行处理。
举个例子,可能有 10 种 Archetype,其中可能有 5 种 Archetype 都有 position 这种 Component。当想要处理所有 position 这些数据计算的时候,首先执行 Query,查询所有有 position Component 的这些 Entity,可以把它查询出来,并且连续放在内存里面。
Query 结束之后,下一步就是执行 System 里面的代码,会顺序处理所有的数据。因为这些数据都是连续存储的,会非常快速地拷贝到 CPU 的 Cache 里面,数据计算就会非常迅速。
这里其实有几个注意点。首先,Entity 里面是没有数据的,它和老的 GameObject 是不一样的。GameObject 每一个对象里面存储了自己的数据,有自己的脚本,去处理自己的业务逻辑,但是到了 ECS 之后,Entity 是没有数据的,所有的数据放在 Component 里面。System 里面的代码先做 Query,Query 出来需要的数据之后再对它进行处理。
ECS概念
ECS是Entity – Component Data – System
1.Enity的作用是关联多个组件数据,作为对象的唯一索引
2.CompnentData的作用是保存该组件所需要的数据字段
3.System的作用是遍历,筛选,再进行逻辑处理和数据处理
传统GameObject和ECS的代码结构比较
JobSystem和Burst
关于Job System
Job System提供了编写简单且安全的多线程代码,便于使用所有可用的CPU内核来执行代码,这有助于提高程序的性能
Job System线程安全
为了更轻松地编写多线程代码,作业系统有一个安全系统,可以检测所有潜在的竞争条件,并保护您免受它们可能导致的错误的影响。当一个操作的输出取决于另一个进程的时序时,就会发生争用条件,而另一个进程不受其控制。
例如,如果作业系统将对主线程中代码中的数据引用发送到作业,则它无法验证主线程是否在作业写入数据的同时读取数据。此方案将创建争用条件。为了解决这个问题,作业系统向每个作业发送它需要操作的数据副本,而不是对主线程中数据的引用。此副本隔离数据,从而消除争用条件。作业系统复制数据的方式意味着作业只能访问 Blittable数据类型
Blittable数据类型 .Net的定义文档:https://learn.microsoft.com/en-us/dotnet/framework/interop/blittable-and-non-blittable-types
IJob接口代码
void Update()
{
new MyJob().Schedule();
}
struct MyJob : IJob
{
public void Execute()
{
Debug.Log($"我运行的线程ID为{Thread.CurrentThread.ManagedThreadId}");
}
}
由上面代码可以看出来,定义一个结构体MyJob,实现接口方法Execute,方法内部打印所在当前线程的ID,在Mono脚本的Update中执行代码new MyJob(){ }.Execute();看输出可以知道,其一直运行在线程ID为1的线程上面,线程ID为1的线程为Unity的主线程,由此可以看出,Job是可以直接运行在主线程上面的,当然,本身不是多线程就没必要去使用IJob接口了(笑)。把执行的代码改成new MyJob(){ }.Schedule()的时候,就可以打印出来不同的线程ID了,既多线程运行。
稍微把代码改一下,不用先前那种匿名的写法,拿到对应的Job对象,调用job.Schedule(),通过这个调用返回一个JobHandle,再通过JobHandle对象调用Complete,这个意思是指,主线程必须要等这个Job的任务完成才会执行jobHandle.Complete()的下一条语句。
void Update()
{
MyJob job = new MyJob();
JobHandle jobHandle = job.Schedule();
jobHandle.Complete();
}
struct MyJob : IJobFor
{
public void Execute(int index)
{
Debug.Log($"我运行的线程ID为{Thread.CurrentThread.ManagedThreadId},job索引为{index}");
}
}
IJobFor接口
可并行运行任务。每个并行运行的工作线程都有一个独占索引,用于安全地访问工作线程之间的共享数据。IJobFor接口可以并行执行,可以单个Job线程执行。
void Update()
{
MyJob job = new MyJob();
job.Schedule(10,default);
}
struct MyJob : IJobFor
{
public void Execute(int index)
{
Debug.Log($"我运行的线程ID为{Thread.CurrentThread.ManagedThreadId},job索引为{index}");
}
}
编写代码如下,修改MyJob实现IJobFor接口,执行代码修改为MyJob jon = new MyJob(); job.Schedule(10,default);这里设定这个Job组的长度为10,意味着执行时候会切换执行长度次,第二个参数为一个JobHandle类型,意思是说需要等这个传入的JobHandle的Job任务都完成了才会开始调度这个Job组。IJobFor的对象使用Schedule方法启动调度Job组的时候,它会优先在一个Job线程上执行完这个Job组的所有任务。使用ScheduleParallel方法的时候,可以配置让继承IJobFor的Job组的Job在执行循环中,会被其他Job线程窃取,可以执行为并发。
IJobFor代码小例子
struct VelocityJob : IJobFor
{
[ReadOnly] public NativeArray<Vector3> velocity;
public NativeArray<Vector3> position;
public float deltaTime;
public void Execute(int i)
{
position[i] = position[i] + velocity[i] * deltaTime;
}
}
上面代码定义了继承了IJobFor接口用来计算物体根据向量运动方向计算坐标的一个Job组,右边是的代码,这里使用了NativeArray容器来保存坐标数据,通过这个容器复制给Job对象来保证线程安全。因为IJobFor下会执行自身的Job组的循环,因为有500个物体则需要运算500次,所以Job组也需要循环500次。
public void Update()
{
var position = new NativeArray<Vector3>(500, Allocator.Persistent);
var velocity = new NativeArray<Vector3>(500, Allocator.Persistent);
for (var i = 0; i < velocity.Length; i++)
velocity[i] = new Vector3(0, 10, 0);
var job = new VelocityJob()
{
deltaTime = Time.deltaTime,
position = position,
velocity = velocity
};
JobHandle sheduleJobDependency = new JobHandle();
JobHandle sheduleJobHandle = job.Schedule(position.Length, sheduleJobDependency);
sheduleJobHandle.Complete();
Debug.Log(job.position[0]);
position.Dispose();
velocity.Dispose();
}
这里把position,velocity的NativeArray容器复制给对应的Job里面来保证线程安全,这里的数据拷贝其实消耗花费不高,因为NativeArray是一个非托管的在C#代码里面运行的C++对象指针,复制的时候,仅仅是复制了一下指针。有因为NativeArray对象不是GC托管的,所以需要使用计算结束后调用Dispose方法将其释放掉。
IJobParallelFor接口
每个并行运行的工作线程都有一个独占索引,用于安全地访问工作线程之间的共享数据。这个接口使用方法和IJobFor接口使用方法相同,至少其内部执行Job组的循环的时候,必定会发生其他Job线程的窃取。
Unity.Collections
此包提供可用于作业和突发编译代码的非托管数据结构。
此软件包提供的集合分为三类:
1.名称以 开头的集合类型具有安全检查,以确保它们已正确处置并以线程安全的方式使用。 Native-
2.名称以 开头的集合类型没有这些安全检查。 Unsafe-
3.其余的集合类型不会分配,也不包含指针,因此它们的处理和线程安全性从来都不是问题。这些类型仅包含少量数据。
字符串类型 NativeText、 FixedString32Byte是可以在JobSystem+Burst下使用的字符串
文档地址
Burst编译器
Burst 是使用 LLVM 从 IL/.NET 字节码转换为高度优化的本机代码的编译器。
手册地址:https://docs.unity.cn/cn/2020.3/Manual/com.unity.burst.html
知乎文章介绍:https://zhuanlan.zhihu.com/p/623274986
[BurstCompile]
struct CalcJob : IJob{
public void Execute() {
//todo 计算
}
}
Burst在代码里面的用法仅仅是给对应的代码块加上[BurstCompile]特性,加上这个特性之后,Burst就会对着块代码进行编译通过Unity的菜单栏,Jobs>Burst>Open Inspector。通过这个Inspector窗口就能开到对应的Burst编译器的编译情况了,我们在CalcJob结构体上加上了[BurstCompile]标签所以这个窗口能够看到CalcJob编译的中间代码。
简单编写一个Job程序
首先简单些一段代码,这段代码的作用是每一帧运算 1000 * 10000 次开根号计算。
void Update(){
for(int i = 0;i<1000;i++){
for(int j = 0;j < 10000;j++){
Mainf.Sqrt(2.0f);
}
}
}
再unity场景中挂入空物体,然后运行场景。运行场景后打开Profiler工具(Window>Analysis>Profiler)观察CPU的使用情况,如下图:
我们会发现每帧运行161ms左右。
再稍微修改一下下代码。
[Serialize]
public bool UseJobSystem;
void Update()
{
if (UseJobSystem)
{
NativeArray<JobHandle> jobHandles = new NativeArray<JobHandle>(1000, Allocator.TempJob);
for (int i = 0;i < 1000;i++)
{
CalcJob calcJob = new CalcJob();
JobHandle jobHandle = calcJob.Schedule();
jobHandles[i] = jobHandle;
}
JobHandle.CompleteAll(jobHandles);
jobHandles.Dispose();
}
else
{
int result = 0;
for (int i = 0;i<1000;i++)
{
for (int j = 0;j < 10000;j++) {
Mathf.Sqrt(2.0f);
}
}
}
struct CalcJob : IJob
{
// public NativeArray<int> result;
public void Execute()
{
for (int i = 0;i<10000;i++)
{
Mathf.Sqrt(2.0f);
}
}
}
这里使用一个结构体来继承IJob接口,并且使用NativeArray来管理JobHandle。
打开UseJobSystem变量。观察Profile面板
发现每帧的耗时降到了30ms。再详细看一下线程使用情况
会看到主线程做了Job线程的作业调度,然后等待所有Job线程的调度完成。所以说使用Job的情况下,因为使用了多线成分担了这些计算量,所以速度也提升了,因为Job线程有四个,所以速度大概提升了四倍左右。
我们再在结构体CalcJob上面添加一个[BurstCompile]特性。如下
[BurstCompile]
struct CalcJob : IJob
{
// public NativeArray<int> result;
public void Execute()
{
int a = 0;
for (int i = 0;i<10000;i++)
{
a = (int)Mathf.Sqrt(2.0f);
}
}
}
耗时可以降到1.11ms
BurstCompile特性是使用一种优化过指令的编译方式了生成中间代码,低层使用了SIMD(Single Instruction Multiple Data),单指令多数据流,一个指令多多个数据进行处理。
Package包的使用
Unity的环境搭建
IDE和编辑器的版本支持
Unity版本为2022.3.0f1或以上
Visual Studio 2022或以上
Rider 2021.3.3或以上
安装Package
打开Unity,选中Unity的菜单Window->Packages Manager。点击左上角+号。点击Add Package by name。
把com.unity.entities.graphics拷贝上去并且点击Add,等待添加Package下载安装完成。如下图
Unity的Entities包
Entities 包是 Unity 面向数据的技术堆栈 (DOTS) 的一部分,它提供了实体组件系统 (ECS) 架构的面向数据的实现。
文档地址:https://docs.unity.cn/Packages/com.unity.entities@1.0/manual/index.html
使用流程
1.场景内创建子场景
因为ECS的场景是需要和Game Object的场景分开的,所以我们需要在Unity的场景中建立一个Sub Scene小场景。在Hierarchy面板里选择鼠标空白处,选择New Sub Scene-》Empty Scene
2.创建一个System
创建一个脚本文件,名字叫CubeMoveSystem.cs,里面进行编写如下。
文件内编写了一个CubeMoveSystem的结构体,这个结构继承ISystem接口必须要使用partical进行修饰,因为Unity会生成这个结构体的其他部分代码。这个时候启动就能看到Console面板的打印。
场景中是需要将Authoring经过烘培转换运行时的实体的。
在对应的Inspector面板可以查看到对应的Authoring和Running Time选择Authoring模式就是查看Mono脚本下的状态,选择Mixed就是可以查看两种的状态,选择Runtime可以查看这个Authoring转换成的Entity的情况。
3.对场景的东西进行烘焙(Game Object转换Entity)
子场景中创建一个空节点,并且命名为GameObjectManager。
再创建两个脚本,文件名GameObjectManagerAuthoring和CubeAuthoring.CS。编写以下代码。编写完成后,脚本挂载到GameObjectManager节点下。
两个都是可以挂载带Unity节点上的脚本类型,我们把GameObjectManagerAuthoring脚本挂到子场景的Game Object Authoring字点下,创建一个3D物体Cube,把CubeAuthoring脚本挂载到Cube上面并把Cube做成预制体。把预制体挂到GameObjectManagerAuthoring的CubePrefba字段上面去。
添加两个组件的声明一个是CubeData的结构体,另外一个是GameObjectData的结构体,并且都继承IComponentData接口,都是用来保存上面两个Authoring对象数据的组件。
再进行烘焙脚本的编写,声明一个GameObjectManagerBaker类,基类为Baker,这里的泛型模板为Baker,因为是要把GameObjectManagerAuthoring烘焙成Entity类型,注意这里烘焙实际上只会进行一次,当I System接口的代码场景一个新的Entity的时候是进行自我拷贝的。编写脚本如下:
Authoring类就是转换成Entity前的GameObject对象,通过烘焙的方式生成Entity,把Authoring原本的数据拷贝进一些组件上面,比如坐标、缩放等会拷贝到LocalTransform组件,这里的GameObjectData组件主要是用来保存Cube Authoring生成Entity的原型的,所以只保存一个Entity类型的字段。
再就是进行CubeBaker类的编写。
参数为CubeAuthoring类型,我们需要使用GetEntity方法来创建出一个Entity实例。GetEntity方法第一个参数就是要烘焙的预制体。第二个参数是一个TransformUsageFlags类型,值为Dynamic,为动态的,意思为给这个实体关联上LocalTransform、LocalToWorld等组件,因为Unity已经为用户处理了这些组件,无需用户去维护。下一句代码AddComponent(entity,new CubeData(){ Move Speed = authoring.Move Speed});。这里就是把烘焙出来的实体关联上Cube Data组件。
烘焙相关的代码已经编写完成了,在ECS中所有逻辑都是在System部分完成的,所有我们需要回头处理一下CubeMove System脚本。
修改一下Cube Move System脚本,如下。
这里代码是,给System做一个技术用的Shared Static,保证在Update方法里面保证只创建一个Entity。编写好我们通过SystemAPI类里面提供的查询方法来查询拥有对应组件。System本身就是用来查询遍历用的。通过上面的查询之后,再通过RefRW对坐标的重写写入,从而做到一个物体一直往(1,0,0)方向移动。而使用RefRW的时候,是支持对应组件的数据写入的,如果是RefRO则是只能进行读的操作。
外观封装
我们查询的代码是通过SystemAPI.Query提供进行查询的,如果我们查询的组件数量很多,那么代码就不适合这样编写。这里使用一个继承IAspect,再通过封装好的外观进行查询,这样能够使得代码的可读性更高,看起来更舒适。
世界概念
世界是实体的集合。实体的 ID 号仅在其自己的世界中是唯一的。世界有一个 EntityManager 结构,您可以使用它来创建、销毁和修改世界中的实体。一个世界拥有一组系统,这些系统通常只访问同一世界中的实体。此外,世界中具有相同组件类型的一组实体一起存储在一个原型中,该原型决定了程序中的组件在内存中的组织方式。
默认情况下,当您进入播放模式时,Unity 会创建一个实例,并将每个系统添加到此默认世界中。可以支持多个世界。
系统概念
系统提供将组件数据从当前状态转换为下一个状态的逻辑。例如,系统可能会更新所有移动实体的位置,方法是其速度乘以自上次更新以来的时间间隔。
系统每帧在主线程上运行一次。系统被组织成系统组的层次结构,您可以使用这些层次结构来组织系统应更新的顺序。
可以在实体中创建非托管系统或托管系统。若要定义受管系统,请创建一个继承自 SystemBase 的类。若要定义非托管系统,请创建继承自 I System 的结构。更多信息,请参见系统概述。
两者都有三种方法可以重写:、 和 。系统的方法每帧执行一次。Isystem SystemBase OnUpdate OnCreate OnDestroy OnUpdate
一个系统只能处理一个世界中的实体,因此系统与特定世界相关联。可以使用 World 属性返回系统附加到的世界。
原型概念
原型是世界中具有相同唯一组件类型组合的所有实体的唯一标识符。例如,世界中具有组件类型 A 和 B 的所有实体共享一个原型。具有组件类型 A、B 和 C 的所有实体共享不同的原型,并且具有组件类型 A 和 Z 的所有实体共享另一个原型。
在实体中添加或删除组件类型时,世界的 EntityManager 会将实体移动到相应的原型。例如,如果某个实体具有组件类型 A、B 和 C,并且您删除了其 B 组件,则 会将该实体移动到具有组件类型 A 和 C 的原型。如果不存在这样的原型,则创建它。
基于原型的实体组织意味着按实体的组件类型查询实体非常有效。例如,如果要查找具有组件类型 A 和 B 的所有实体,则可以查找具有这些组件类型的所有原型,这比扫描所有单个实体的性能更高。世界中现有的原型集往往会在程序生命周期的早期稳定下来,因此您可以缓存查询以获得更快的性能。
只有当原型的世界被摧毁时,原型才会被摧毁。
原型块
具有相同原型的所有实体和组件都存储在称为块的统一内存块中。每个块由 16KiB 组成,它们可以存储的实体数量取决于块原型中组件的数量和大小。EntityManager 根据需要创建和销毁区块。
块包含每个组件类型的一个数组,以及一个用于存储实体 ID 的附加数组。例如,在具有组件类型 A 和 B 的原型中,每个块都有三个数组:一个数组用于 A 组件值,一个数组用于 B 组件值,一个数组用于实体 ID。
块的数组是紧密打包的:块的第一个实体存储在这些数组的索引 0 中,块的第二个实体存储在索引 1 中,后续实体存储在连续索引中。将新实体添加到区块时,该实体将存储在第一个可用索引中。当一个实体从块中删除时(因为它被销毁或被移动到另一个原型),块的最后一个实体被移动以填补空白。
将实体添加到原型时,如果原型的现有块全部已满,则创建一个新块。当从块中删除最后一个实体时,将销毁该块。
结构变化概念
导致 Unity 重新组织内存块或内存块内容的操作称为结构更改。重要的是要了解哪些操作是结构性更改,因为它们可能是资源密集型的,并且您只能在主线程上执行它们;不是来自工作。
以下操作被视为结构更改:
1.创建或销毁实体。
2.添加或删除组件。
3.设置共享组件值。
标签组件
标记组件是非托管组件,不存储任何数据,也不占用空间。
从概念上讲,标签组件的用途与游戏对象标签类似,它们在查询中很有用,因为您可以按实体是否具有标签组件来筛选实体。例如,您可以将它们与清理组件和筛选实体一起使用以执行清理。
给对应的对象继承,IEnableableComponent可以控制组件的开关。如果继承了IEnableableComponent接口,在通过这句代码把这个组件给关闭掉。
共享组件
共享组件根据其共享组件的值将实体分组到块中,这有助于消除重复数据。组件继承ISharedComponentData接口。共享组件的修改和设置会引起结构变化
IJob Entity接口
这个的使用方法和IJobFor接口的使用方法类似,IJobEntity根据其“Execute”方法的参数生成组件数据查询。继承这个接口的结构体可以配合Burst编译器一起使用,大大提高程序性能。
把向前的CubeMoveSystem的OnUpdate方法移动物体的代码移动到声明的CubeMoveJob方法里面就能在多线程下移动物体坐标。CubeMoveJob的Execute参数由”ref CubeAspect queryRes”填写成”ref LocalTransform localTransform”就会变成查询所有的LocalTransform的组件的实体调用进来。
因为前面的CubeDataz组件的代码继承了IEnableComponent,可以这样编写来查询出来这个组件是否被激活还是开启,RW结尾就是可读可写,RO结尾就是仅仅可读
IJob Entity依赖
在System的Update使用Job Entity的调度的时候,可以使用当前这个世界的上下文调用CompleteDependency方法,调用这个方法后,是必须要等所有的CubeMoveJob的调度执行完了才会调用往后的代码。不如有两个Job,第二个Job需要等第一个Job执行玩了才往下执行。
Entity Command Buffer
EntityCommandBuffer简称ecb,主要保存对Entity操作,因为在操作Entity各种组件时,大多数情况下都是并发的情况,那么我们就需要把这些操作结束后产生的结果收集起来,在某一个指定的时刻按顺序修改到有修改的实体的组件上面去,这主要是解决了多线程安全物体。下面直接贴一段代码。
[BurstCompile]
public void OnUpdate(ref SystemState state) {
EntityCommandBuffer.ParallelWriter ecb = GetEntityCommandBuffer(ref state);
new ProcessSpawnerJob { ElapsedTime = SystemAPI.Time.ElapsedTime, Ecb = ecb }.ScheduleParallel();
}
private EntityCommandBuffer.ParallelWriter GetEntityCommandBuffer(ref SystemState state) {
var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
return ecb.AsParallelWriter();
}
[BurstCompile]
public partial struct ProcessSpawnerJob : IJobEntity {
public EntityCommandBuffer.ParallelWriter Ecb;
public double ElapsedTime;
private void Execute([ChunkIndexInQuery] int chunkIndex, ref Spawner spawner) {
if (spawner.NextSpawnTime < ElapsedTime) {
Entity newEntity = Ecb.Instantiate(chunkIndex, spawner.Prefab);
Ecb.SetComponent(chunkIndex, newEntity, LocalTransform.FromPosition(spawner.SpawnPosition)); w spawner.NextSpawnTime = (float)ElapsedTime + spawner.SpawnRate;
}
}
}
关于Job System + Burst 于Game Object的相互通讯
Burst 对访问静态只读数据具有基本支持。但是,如果要共享静态可变数据,使用结构如下。
public static class MutableStaticTest {
public static readonly SharedStatic<int> IntField = SharedStatic<int>.GetOrCreate<MutableStaticTest, IntFieldKey>();
// Define a Key type to identify IntField
private class IntFieldKey {}
}
在IntField声明的字段里面的数据,Burst和Game Object部分都可以进行访问。
相关工程地址
Demo的主要作用是为了比较使用DOTS方案实现多怪物场景和Game Object方案实现性能差异。
所以Demo有两个场景一个是Dots实现的一个是使用GameObject方式实现的。
工程链接:https://github.com/kof123w/DotsProject
工程的运行环境:
Unity版本 2022.3.14及以上 前置依赖包 Entities.Graphics,Unity.Physics,Unity.Burst,Univerisal RP
渲染管线 Universal Renderer Pipeline文章来源:https://www.toymoban.com/news/detail-852566.html
参考文档
ECS官方文档文章来源地址https://www.toymoban.com/news/detail-852566.html
到了这里,关于Unity Dots学习内容记录的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!