大家好,我是阿赵。
之前学习过Unity官方的DOTS相关Demo,似乎掌握了一点相关的知识。但可以很负责任的讲一句,不亲手做一做,永远都不会知道有些什么问题。
所以我自己做了个Demo来感受一下使用Unity的DOTS的过程。接下来主要是谈一下一些体会。先说明一下,这篇文章是在之前的介绍DOTS官方例子的基础上写的,所以基础用法介绍就不再重复了,只是谈一下实际改造过程中的一些东西。
一、 Demo介绍

这是一个堆怪游戏的Demo,中间绿色的是主角,然后红色和蓝色的是不同类型的敌人,敌人有远程攻击和近战攻击,主角也有远程和近战武器。
这个游戏的做法,如果不使用DOTS,而是使用面向对象的话,就非常简单了:
首先有角色的数据类,包括了角色的基本信息,包括当前位置、当前血量、武器等,这些数据以对象的形式存在数据Model层。
然后建立实体对象,包括:
1. 单位对象,对象包括了移动、攻击、扣血、死亡等方法。
2. 子弹对象,包括了子弹的移动方法和检测是否命中的方法。
这些数据和对象,都有唯一的id,可以通过id从model层直接获取。
最后要做的事情就很简单了,遍历这些对象,然后单位对象就执行移动、攻击的方法,判断怎样移动到对象和发起攻击,子弹对象执行移动和检测是否命中的方法。如果子弹命中,则给命中对象执行扣血方法,然后判断是否死亡。
Demo很简单,不过由于很多方法没有特意去优化,比如碰撞的分区域检测等,所有逻辑都是靠着直接遍历去执行,所以这个Demo效率不算很高,敌人人数达到400人左右,fps就已经不够30了。

二、 Jobs改造
从最简单的理解,Jobs能实现的,就是把一部分逻辑通过多线程来完成。
那么具体是怎样使用的呢?
比如说,我的Demo里面有一个逻辑是敌人移动的,规则是敌人向主角所在位置靠拢,但相互之间又要互相挤开,而且不能超出地图的边界。
如果不用Jobs,我的做法是把遍历逻辑写在敌人单位对象里面:
private void CheckOneEnemyThink(UnitEntity entity)
{
UnitEntity player = GetModel().GetPlayer();
if(player == null)
{
return;
}
UnitData playerData = GetModel().GetUnitData(player.id);
UnitData selfData = GetModel().GetUnitData(entity.id);
if(playerData == null||selfData == null)
{
return;
}
float dirX = playerData.pos.x - selfData.pos.x;
float dirY = playerData.pos.z - selfData.pos.z;
Vector2 vec = new Vector2(dirX, dirY);
vec.Normalize();
selfData.lookatX = vec.x;
selfData.lookatY = vec.y;
Dictionary<int, UnitEntity> dict = GetModel().GetEnemyDict();
bool canMove = true;
float nextX = selfData.pos.x + Time.deltaTime * selfData.dirX * selfData.moveSpeed;
float nextZ = selfData.pos.z + Time.deltaTime * selfData.dirY * selfData.moveSpeed;
float radius1 = selfData.radius;
float radius2 = 0;
float offsetX = 0;
float offsetZ = 0;
float forceX = 0;
float forceZ = 0;
UnitData otherData;
foreach(var item in dict)
{
otherData = GetModel().GetUnitData(item.Value.id);
if(otherData == null||otherData.id == selfData.id)
{
continue;
}
radius2 = otherData.radius+radius1;
offsetX = selfData.pos.x - otherData.pos.x;
offsetZ = selfData.pos.z - otherData.pos.z;
if(offsetX*offsetX+offsetZ*offsetZ<radius2*radius2)
{
canMove = false;
forceX += offsetX;
forceZ += offsetZ;
}
}
if(canMove)
{
selfData.dirX = vec.x;
selfData.dirY = vec.y;
}
else
{
Vector2 force = new Vector2(forceX+vec.x, forceZ+vec.y);
force.Normalize();
selfData.dirX = force.x;
selfData.dirY = force.y;
}
}
然后再在外面遍历所有的敌人:
private void CheckEnemyThink()
{
Dictionary<int, UnitEntity> dict = GetModel().GetEnemyDict();
if (dict == null || dict.Count == 0)
{
return;
}
foreach(var item in dict)
{
CheckOneEnemyThink(item.Value);
}
}
这些没有经过优化的代码,都是存在大量的遍历的,虽然说这些都是可以避免的,但我这里为了让性能更容易出问题,所以不去优化了。
从单线程处理来说,这样两层循环会在主线程一直执行到结束,才会继续往下走。所以敌人数量如果变多,那么遍历的计算是几何级数的增加。
既然是这样,如果是用多线程来解决这个问题,是不是就可以了?
的确是这样,我们其实也可以在C#自己开线程来处理这些问题。不过存在一些限制:
1. Unity的API运行限制
Unity引擎提供的API,都只能运行在主线程,如果在子线程运行会报错。
2. 多线程之间数据锁的问题
如果开多线程处理一堆数据时,如果不同的线程都可能对同一组数据进行修改,这就会存在锁的问题,当一个线程正在处理该数据时会把数据锁住,这个时候其他线程是不能去修改这个数据的。
接下来看看Jobs,如果不是我们自己开线程,而是使用Jobs来处理,会不会就没有这些问题呢?
Jobs的使用也有它的限制
1. 数据的准备
为了让它尽量不需要在运行中从外部获取数据,所以一般来说,创建Jobs的时候,就会先考虑它需要哪些数据,然后赋值给它,让Jobs在运行中直接就有数据。
比如上面举的例子,敌人移动,它需要的数据有
(1) 主角的位置
(2) 当前帧的deltaTime
(3) 所有敌人的位置
(4) 所有敌人的速度
(5) 所有敌人的半径
然后需要根据上面的数据,计算出这些东西:
(1) 所有敌人的朝向
(2) 所有敌人的下一帧移动位置
所以我如果需要建一个Job去计算这些敌人的移动,就需要这样:
public struct CheckEnemyMoveJob : IJobParallelFor
{
[ReadOnly] public float3 playerPos;
[ReadOnly] public float deltaTime;
[ReadOnly] public NativeArray<float3> enemyPos;
[ReadOnly] public NativeArray<float> enemySpeed;
[ReadOnly] public NativeArray<float> enemyRadius;
public NativeArray<float2> lookatDir;
public NativeArray<float3> nextPos;
带有ReadOnly的数据就是刚才说到的需要计算时用到的数据比如playerPos主角位置,enemyPos是敌人的位置,等。
而没有带ReadOnly的,就是需要返回的数据,比如lookatDir就是下一帧的朝向,nextPos就是下一帧的位置。它是使用引用的方式,从外部给它一个数组,然后在Jobs运行的时候给数组赋值,当Jobs完成运行之后,外部就可以拿到这些数据当做返回值。
每次在创建Job的时候,需要先对这些数据进行赋值:
NativeArray<float3> enemyPos = new NativeArray<float3>(enemyDataList.Count, Allocator.Persistent);
NativeArray<float> enemySpeed = new NativeArray<float>(enemyDataList.Count, Allocator.Persistent);
NativeArray<float> enemyRadius = new NativeArray<float>(enemyDataList.Count, Allocator.Persistent);
NativeArray<float2> lookatDir = new NativeArray<float2>(enemyDataList.Count, Allocator.Persistent);
NativeArray<float3> nextPos = new NativeArray<float3>(enemyDataList.Count, Allocator.Persistent);
for (int i = 0; i < enemyDataList.Count; i++)
{
UnitData ud = enemyDataList[i];
enemyPos[i] = ud.pos;
enemySpeed[i] = ud.moveSpeed;
enemyRadius[i] = ud.radius;
}
CheckEnemyMoveJob job = new CheckEnemyMoveJob
{
playerPos = playerPos,
deltaTime = deltaTime,
enemyPos = enemyPos,
enemySpeed = enemySpeed,
enemyRadius = enemyRadius,
lookatDir = lookatDir,
nextPos = nextPos
};
2. 数据的互锁问题
上面的例子可以看到,CheckEnemyMoveJob 是继承了IJobParallelFor接口的。Job还可以继承IJob接口,这两者的区别是:
IJob接口:
它的Execute方法是不带index参数的,在调用job.Schedule方法的时候,也不需要带参数。当调用Schedule时,实际上是整个Execute方法一起调用,所以在属性里面定义的所有参数,它都是可以正常访问的,包括可以写入返回的参数也是可以正常访问。
IJobParallelFor接口:
它的Execute方法是带index参数的,而job.Schedule方法是需要传入需要操作的数据的长度,还有切片的长度的。
怎么理解?举个例子,比如我上面的例子,有1000个敌人需要逐个去做判断,那么需要操作的长度就是1000。然后切片长度就是设置每一个批次需要处理的数据数量。比如如果我填了切片数量是100,那么数据会被裁剪成很多份,第一份数据的index是0-99,第二份数据时100-199,依次类推。在调用index的时候,index肯定是在范围内的,比如index为1时,它的数据范围肯定是0-99的。
为什么要给数据切片?因为在0-99这个批次里面,如果它需要修改数组返回,它也只能处理对应下标是0-99下标那些数据,比如如果你想在0-99里面计算出第101个数据并存起来,是不行的。这个有点类似于多线程的锁,不过更绝对一点,预先分配好下标范围了。所以那些只读不需要改变的数据,就要加入ReadOnly,这是告诉Job,这些数据没有锁的问题,可以在任意段的数据都能访问到。不然就只能访问自己范围内的。
这里就会有个问题,如果我返回的数据的下标并不是和需要处理的数据下标长度一样,而是通过别的计算方式计算出来的,那怎么办呢?其实也是可以的,只需要写一个NativeDisableParallelForRestriction就可以了。比如我在计算子弹攻击的时候,一个子弹可能会打到多个敌人,但这些存储是不可能重复写入的,所以就可以这样定义这个数组:
[NativeDisableParallelForRestriction] public NativeArray<int> bulletHitEnemyList;
所以简单点来说,Jobs想它运行得足够快,首先要把它需要的数据直接组装好给它,让它能连续的读取数据。然后需要合理的切片数据,让它的批次更合理。
理解了之后,把之前的多次循环的逻辑,比如敌人的移动、子弹的移动、子弹碰撞判定等都改成了用Jobs切片的方式来做,就可以用多线程的方式加快这些循环的运行。
三、 Brust
Brust编译器在DOTS的几部分里面,一开始是最让我忽略的,因为介绍比较笼统,只是说可以提高性能。但在实际用过之后,发现它配合着Jobs用,效果还是很明显的。
Brush的使用很简单,在你需要的地方加上[BurstCompile]就行了。这些地方可以是整个Job的struct,也可以是某个方法,比如单独给System的OnUpdate方法加上[BurstCompile],而OnCreate方法不加,也是可以的。
虽然说使用很简单,不过想用brust,是需要遵守它的一些规则的。
1、 不能构造对象
比如我有一个叫做UnitData的class,假如我在指定了[BurstCompile],然后这样写:
UnitData data = new UnitData();
会报错:
Burst error BC1021: Creating a managed object
UnitData..ctor(...)is
not supported
这说明了类的构造函数是不支持的。
2、 不能用单例
比如我有一个数据Model,需要从里面用单例来取一个对象的数据,我这样写:
UnitData data = SceneDataModel.Instance.GetUnitData(0);
会报错:
Burst error BC1016: The managed function
SceneDataModel.get_Instance()is not supported
所以不能用单例的方法来访问。不过静态方法是可以的。
3、 List数组不能用
比如我想新建一个List数组:
System.Collections.Generic.List<int> tempList = new System.Collections.Generic.List<int>();
会报错:
Creating a managed object `System.Void System.Collections.Generic.List`1<System.Int32>::.ctor()` is not supported
数组需要使用NativeArray或者NativeList,比如
NativeArray<int> tempArr = new NativeArray<int>();
NativeList<int> tempList = new NativeList<int>();
这样是没问题的。
4、 数学方法
在之前使用Unity的数学库是Mathf,包括了使用Vector2和Vector3作为计算的基础。
在使用DOTS的时候,旧的Mathf还能用。不过Unity又提供了另外一套数学库,叫做Unity.Mathematics.math,官方是建议使用新的数学库,因为运行得更快。
这个数学库提供了float2和float3作为计算的基础,然后提供了一些常用的数学运算方法来使用,比如math.normalizesafe、math.distance等。
总结一下,使用Brust编译器,可以让运行速度更快,不过前提是需要使用它的规则来写代码。至于有哪些是不能用的,我这里也没有举例得很完全,其实不用怕,实际写一次就知道了,因为不支持的会有明确的报错,这时候换一种写法就可以了。
然后有些地方必须使用Brust不支持的写法,比如单例,比如查表、查数据等,这是无可避免的,也不用怕,把代码规划好,运行这些方法的地方不要加[BurstCompile]就行了。
不得不说,加上了Jobs和Brust编译器之后,整个运行效率就已经大大的提高了,敌人数量去到3千多,都还可以不掉3千帧。

有可能有些朋友觉得3千多敌人数量并不是很多,的确是不多的,不过我这个是和之前没用Jobs和Brust之前做的对比,一样的没有任何优化的算法,之前只能300多敌人,现在能3千多,只是想证明Jobs和Brust的效果是很明显的。
四、 ECS
对于一个本来是面向对象的项目,要改造成使用Jobs和Brust编译器,真的不难,只是把部分复杂的逻辑需要用多线程的改成用Jobs,然后尽量符合Burst的要求,就能得到性能的改善。
但ECS的改造,成本就会比较高。
ECS是解决什么问题呢?
1、 使用Entity来替代了原来场景里面的GameObject和Mono对象。
场景里面需要建立对象,然后挂上Mono脚本,这里存在的问题是:
1. 对象上面有很多组件
使用Unity的GameObject对象,需要挂很多组件。这些组件会让对象显得很臃肿,浪费很多内存。
2. 执行生命周期很分散
不管是组件也好,还是Mono脚本也好,都是有自己的生命周期的。这些生命周期我们基本上控制不了,是系统自己按照规则调用的,导致了很多无谓的消耗,而且数据读取很分散。
所以ECS里面,干脆就不用GameObject和Mono对象了,而是以一个最简单的对象实体Entity作为基本单位,Entity上面可以放各种的Component,但Entity本身是没有功能性的。
2、 使用Component当作是数据
这里的Component和GameObject上的Component是完全不同的概念。GameObject上面的Component,是一个实现具体功能的模块。但ECS里面的Component,其实只是一个存储数据的结构体,举个例子:
比如我这里有一个Component叫做InputState:
public partial struct InputState : IComponentData
{
public float Horizontal;
public float Vertical;
}
整个Component就是这么简单,其实就是一个结构体。里面存储了2个变量,一个是水平值,一个是垂直值。而这个Component具体是干什么用的,Component自己是不知道的,因为它里面没有任何的逻辑。
再比如,我有一个组件
public struct EnemyThinkCom : IComponentData
{
}
里么是完全没有内容的。这样的Component也是允许的,它其实代表了个标签,添加到Entity实体之后,某个System会找到带有这个Component的实体,进行某些逻辑。
使用Component当作数据的存储,好处是可以把同一个类型的Component数据存储在一起,作为一个连续的内存读取。在System里面,大部分的操作都是遍历同一个类型的Component进行逻辑处理。
3、 使用System来快速获取和遍历实体和Component
System是针对某些Component来执行的,主导思想是把某一种的功能逻辑全部集中到某个System,然后遍历这一种功能逻辑需要用到的某种Component,把这个功能实现了。
System继承ISystem接口,是带有3个生命周期的,分别是OnCreate、OnUpdate和OnDestroy,这三个方法都是带有ref SystemState state作为参数的。
在OnCreate的时候,可以进行一些判断,比如说可以指定这个System需要某些Component存在才能运行Update。举个例子,比如BulletSystem是处理子弹逻辑的,所以可以在OnCreate里面写
state.RequireForUpdate<BulletCom>();
这样写了之后,当场景里面某些Entity存在BulletCom这个Component,BulletSystem的OnUpdate才会被执行。如果场景里面没有任何实体上面带有BulletCom,那么BulletSystem的OnUpdate也就不会执行了。
而OnUpdate是主要的声明周期,在每一帧都会调用。在System里面,都是通过获取Component的单例,或者遍历某些Component来进行逻辑的。
所以在使用System的时候,首先要知道怎样去获取component单例或者筛选遍历想要的component队列。
1. 获取Component单例
var config = SystemAPI.GetSingleton<Config>();
这里我有一个叫做Config的Component,是用来记录各种基础信息数据的,所以我可以使用SystemAPI.GetSingleton来获取。不过需要注意的是,假如想要的Component在场景里面不止一个实体拥有,而是有多个,是不能用SystemAPI.GetSingleton来获取的,不然会报错
比如我想获取EnemyThinkCom组件,但EnemyThinkCom是每个敌人身上都有的,如果我用了SystemAPI.GetSingleton来获取,就会报错:
Can’t call GetSingleton() with zero-size type
EnemyThinkCom.
2. 获取Component队列
如果想获取一些特定的Component,比如,在一个敌人的实体身上,会同时存在LocalTransform、UnitBaseData、EnemyThinkCom三种组件,而主角身上虽然会有LocalTransform、UnitBaseData这两种组件,却不会有EnemyThinkCom主见的, 所以假如我想给所有的敌人进行一些逻辑,我可以这样遍历:
foreach(var(transform, unitbase, thinkCom) in SystemAPI.Query<RefRW<LocalTransform>,RefRW<UnitBaseData>, RefRO<EnemyThinkCom>>())
{
//想做的逻辑
}
这里的意思是,通过SystemAPI.Query方法,找到实体上面同时拥有LocalTransform、UnitBaseData、EnemyThinkCom三种组件的情况,这样就可以通过
var(transform, unitbase, thinkCom)
来接收着三个组件的数据,并且使用了。
需要注意的是,如果使用 SystemAPI.GetSingleton来获取组件,是不分可读写还是只读的,我们可以直接使用里面的变量,比如
var config = SystemAPI.GetSingleton<Config>();
float sizeX = config.mapSizeX;
但如果使用SystemAPI.Query来获取,那么指定的类型前面需要加RefRW或者RefRO,这两者的理解也很简单,RefRW是可读写的,而RefRO是只读的。
比如
transform.ValueRW.Position.x = x;
由于transform使用的是RefRW,所以它是可读写的,我们可以通过transform.ValueRW来访问并写入它的值。
如果是使用RefRO来声明的,transform就没有ValueRW,只有ValueRO,它是只读的,不能写入,例如:
var x = transform.ValueRO.Position.x;
3. 获取带有某些Component的实体
有时候我们想直接操作实体本身,比如想把某些符合条件的实体删除,那么可以这样:
foreach (var (bulletcom, entity) in SystemAPI.Query<RefRO<BulletCom>>().WithAll<BulletCom>().WithEntityAccess())
{
if(bulletcom.ValueRO.isDispose == 1)
{
ecb.DestroyEntity(entity);
}
}
这里我通过.WithAll().WithEntityAccess()来获取带有BulletCom组件的实体,然后接收是在前面的var里面声明的,var(bulletcom,entity),其中bulletcom就是后面需要的RefRO,而entity就是.WithAll().WithEntityAccess()的实体本身了。
4. EntityManager和EntityCommandBuffer
在ISystem的生命周期方法里面,都带有ref SystemState state这个参数,通过state可以获取到当前世界的实体管理器:
var entityManager = state.EntityManager;
这个EntityManager可以管理场景里面的所有实体,通过CreateEntity系列方法创建新的实体,通过AddComponent、SetComponentData、RemoveComponent、GetComponentData等方法操作实体身上的Component。
不过有个限制是,在遍历的过程中,System是不允许改变实体结构的。所以有很多时候,我们不能直接通过EntityManager来操作实体的组件,而需要通过EntityCommandBuffer来做。
EntityCommandBuffer相当于是先注册一系列的操作命令,然后在遍历完之后,统一执行。
使用EntityCommandBuffer的步骤:
(1) 创建EntityCommandBuffer
特别注意的是,EntityCommandBuffer最好是在循环外面就先声明创建:
EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);
Allocator.Temp可以让申请的内存在执行完System之后就回收。
也可以这样申请:
var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
(2) 添加命令
得到了ecb之后,就可以给ecb下命令,
比如实例化:
Entity bullet = ecb.Instantiate(bulletPrefab);
设置组件的值:
ecb.SetComponent(bullet, new LocalTransform
{
Position = transform.ValueRO.Position,
Scale = 1,
});
添加组件:
ecb.AddComponent(bullet, new BulletCom
{
index = index++,
speed = bulletCfg.speed,
aliveTime = bulletCfg.aliveTime,
radius = bulletCfg.radius,
bulletType = bulletCfg.bulletType,
effect = effectPrefab,
effectTime = bulletCfg.effectTime,
damage = levelCfg.baseDamage,
dir = GetDirByAngle(currentAngle),
isDispose = 0,
hitCount = levelCfg.hitCount
});
或者销毁实体:
ecb.DestroyEntity(entity);
(3) 执行命令
上面添加的命令,是不会立刻执行的,它只是存储到了EntityCommandBuffer里,最后在循环结束之后,已经收集完了所有的命令了,再去执行:
ecb.Playback(state.EntityManager);
(4) 销毁EntityCommandBuffer
一般来说,在执行完了命令之后,EntityCommandBuffer就可以销毁了
ecb.Dispose();
4、 ECS的一些问题
到了这里,ECS系统大概也就能用起来的,这时候运行,场景里面不会有GameObject的产生,然后System会统一遍历相同Component列表快速的执行逻辑,然后把Jobs写在System里面,并且在可以符合Brust编译规则的方法上面写上[BurstCompile],那么整个Unity提供的DOTS框架就基本上使用起来了。
但事情没这么简单,有一些地方,其实不太适合用ECS来做,比如:
1. 读表数据
如果用ECS的思想,应该所有数据都用Component来存储和查询,但实际上很难这样做。比如策划通过Excel表来配的数据,我们一般都需要通过具体准确的id去查找,这些数据在读取的时候是通过id作为索引的数据对象。
这时候,我们还是需要通过把Excel表导出成数据,然后通过特定的方法去查找。
2. 单位信息数据交互
由于我们做游戏很多时候都不止是简单的逻辑,而是需要一些复杂的功能,或者和服务器通过协议去交互数据。所以每个单位的数据,理论上是需要有唯一id作为key,并且根据这个唯一id去查找数据、发送数据和存储数据。
如果所有的数据都真的存在Component里面,做单机逻辑可能问题不大,但做网络游戏可能就会麻烦点。
所以这里需要把功能细分一下,看看哪些是比较耗性能需要使用ECS来做的,就单独把数据抽出来,存在Component里面,其他的逻辑数据,还是需要通过Model层来存储和交互。
比如一般来说,最耗费性能的就是大场景里面的角色模型表现,那么可以做一个ECS系统,单纯的实例化模型实体,并且用Component和System控制他们的位移旋转还有动画,其他数据逻辑就还是用正常的面向对象数据。
3. 传统面向对象的使用
ECS擅长处理的是大批量数据和表现。但如果你真的用过ECS,你会发现这种方式写代码,其实是很痛苦的。举个例子,我的Demo里面,敌人的行为很单纯,只是移动和简单判断范围攻击就可以了,所以大规模的使用ECS,逻辑也不算复杂。但如果是用于主角,主角的逻辑可能会比较复杂,它可能会换装、多技能、坐骑、需要动画融合、根据各种输入做不同的表现。当然,这些需求如果用ECS来写,也是可以实现,不过会变得非常复杂,每增加一个功能,可能就要对应增加一种新的Component和System来实现。
但问题在于,作为主角,它其实只会出现有限的1个或者2个,使用ECS并不能带来很大的性能提升,却在写法上受到了诸多的限制。
这个时候,我觉得就应该考虑是否使用更容易开发的面向对象配合Mono对象去做这些事情了。只需要正常的定义一个主角的类,然后在里面实现各种行为,这些复杂的需求就能简单的完成。
然后还有比如摄像机控制,场景里面一般只有有限的一两个摄像机,但可能需要控制摄像机里面的各种属性,如果要用ECS,我感觉只会把问题复杂化,还不如直接用Unity原生的API配合摄像机对象来操作逻辑。
4. 热更新问题。
我感觉这是最大的问题。在ECS里面,如果想把模型转换成实体显示,必须先通过Authoring来指定预设,然后在编辑器环境下,通过Bake方法转换成实体,比如:
bullet1 = GetEntity(authoring.bullet1, TransformUsageFlags.Dynamic)
然后这些指定实体预设的操作,必须是在SubScene子场景里面拖进去的:

在Unity早期的ECS版本里面,还提供了GameObjectConversionUtility.ConvertGameObjectHierarchy方法来把GameObject在运行时转换成实体,但在最新的版本里面,这些实时转换的方法已经都没有了,只支持在编辑器环境下先转换好再使用。
这种做法,如果只是做小规模的游戏,问题还算小一点,但规模大一点的游戏,其实就很难使用了。
然后想把这些指定好Bake的内容热更新,我是暂时没有找到方法,如果整个Scene打包成AssetBundle并且在运行时加载,会 出现SubScene丢失的问题:

这样整个ECS系统都不能跑起来。
五、 总结
还是要重复一下这句话:看再多都不如动手做一次。
随便拿个Demo试试改成DOTS,基本上就能知道用法和注意事项了。
最后来谈一下我对DOTS的看法。
首先,这肯定是一个非常好而且非常极端的优化方向,特别是现在有很多游戏都是需要大范围多单位战斗的,比如SLG游戏,或者开放式世界的ARPG游戏,用DOTS来改造一下,的确可以比较明显的提高同屏单位展示。
然后,DOTS是一种思路,除了Unity提供的Entities、Jobs和Brust功能以外,我们也可以尝试着用这种思想来编程,把同类的数据放在一起,然后通过统一的遍历同类数据完成逻辑,达到了内存的高速命中访问的目的。
不过我觉得很多事情不是非黑即白,使用面向数据编程,还是面向对象编程,不应该是一个派系之争,而应该是各取所长。
面向对象的优点和缺点都很明显,优点是方便管理、扩展和维护逻辑都很方便清晰。缺点是内存很浪费,调用层级多,导致消耗大,性能不是很理想。
面相数据的优点是性能好,缺点是编写逻辑和扩展维护会比较麻烦。
既然这样,我觉得最好的做法是把游戏项目的性能瓶颈找出来,局部的进行DOTS改造。其他的部分为了快速开发,还是使用面向对象的方式。
至于Unity的ECS功能导致的不能热更新的问题,如果各位大哥有解决的办法,希望各位不吝赐教,阿赵先谢谢各位。
1279

被折叠的 条评论
为什么被折叠?



