DOTS介绍+Unity DOTS-MAN小游戏项目实战


前言

DOTS是Unity在17年左右提出的一个概念,其核心是ECS。


提示:以下是本篇文章正文内容,下面案例可供参考

一、1. What is DOTS and why we use it?

全称:(Multi-Thread)Data-Oriented-Tech-Stack
(多线程式)数据导向型技术堆栈

1.DOTS包含的主要元素(三件套)

  1. 实体组件系统(ECS) - 提供使用面向数据的方法进行编码的框架。在Unity中它通过Entities软件包进行分发,您可以通过Package Manager来添加编辑器。
  2. C#作业系统 (JobSystem)- 提供一种生成多线程代码的简单方法。它通过Jobs软件包进行分发。
  3. Burst编译器 - 可生成快速、优化的本机代码。它通过Burst软件包进行分发,可通过Package Manager在编辑器中使用。
  4. 本机容器 - 属于ECS数据结构,可提供对内存的控制,值得注意的是Unity专门对内存管理进行了一部分优化以降低MissCache。

2.Why we use it?

  1. 许多并行编程范式,尤其是SIMD(单指令多数据)型范式,更倾向于使用SoA(结构体数组)。在CUDA C编程中也普遍倾向于SoA,一维数据元素是为全局内存的有效合并访问而预先准备好的,而相同内存操作引用的同字段元素在存储时时彼此相邻的,使用SoA能够显著减少MissCache。
  2. 实体组件系统(ECS)提供了一种面向数据的编码设计方法。利用面向数据的方法,可以对数据结构加以组织,以免出现高速缓存未命中的情况,从而令随后的数据访问更加高效、快捷。由于面向对象的设计并不专注于数据的组织,因此高速缓存未命中的情况很常见,这样就减慢了CPU访问数据的速度,因为它必须频繁地返回访问主内存中的数据。
  3. C#作业系统可以轻松地用C#编写快速、并行化的代码,以充分利用当今的多核处理器。
  4. Burst编译器会生成高度优化的代码,而这些代码可以利用您要编译的平台硬件。

Tips:

  1. jobsystem和ecs是两个不同的东西,但是配合起来使用会有1+1>2的效果
  2. burst与ecs的高度适配也使得ecs运行效率很高

3.Where we use it? (摘自Unity官方)

除非您在寻求短期或中期的性能改进,否则很难判定是否需要过渡到DOTS或何时过渡到DOTS。
DOTS几乎可以为每个应用程序带来一定程度的性能改进。这其中包括性能、电池使用寿命、迭代及项目可扩展性。过渡到DOTS不会造成任何性能的下降,但评估过渡到DOTS所增加的费用却至关重要,尤其是对于那些仅带来较小改进的项目。
对于所有应用程序而言,DOTS适合处理大量数据,例如开放式环境或使用大量相同材料的复杂结构。通过在实例之间共享公共数据以减少内存访问,DOTS也同样适用于重复的元素。
DOTS将来会帮助您开发高质量的内容,而不使用DOTS的Unity却很难做到,这一点务必要考虑清楚。例如,当今的标准游戏和Unity项目已经取代了过去的AAA游戏。放眼未来,您需要采用DOTS来保持竞争力。
针对不同的垂直行业,DOTS可以适用于不同的解决方案:

(1)对于AEC(工程建设)应用

  • DOTS适合处理大型数据集并确保内容的可扩展性。
  • DOTS非常适合进行大型交互式地图和具有大量模型和重复内容(例如建筑物和道路)的环境设计。
  • DOTS适用于复杂的工程可视化,可大规模地模拟现实环境。例如,DOTS非常适合进行粒度级工厂和基础架构设计。

(2) 对于汽车应用

  • 自动驾驶的仿真和可视化
  • DOTS非常适合进行大型交通和行人模拟,这需要成千上万的志愿Agent以逼真的方式移动和交互。

(3) 对于游戏独立开发者和自由职业者

  • DOTS可以帮助您减轻游戏中一些高成本操作的负担,并有助于提高性能,尤其是对于一些重复性进程。
  • 许多轻量级游戏(例如用于移动设备的游戏)并不能最大限度地提高硬件性能。即使有些游戏能够做到这一点,但这可能并不是它的主要关注点。不过,随着游戏的不断发展和硬件需求的持续增加,明智的做法是为将来使用DOTS做好准备。同样,Project Tiny也提供了使用DOTS开发较小应用程序和游戏的解决方案。
  • 如果您没有使用DOTS的迫切需求,那么最好先未雨绸缪,提高自己的DOTS技能,以便在DOTS成为Unity开发的标准方法时能够整装待发。

(4)对于游戏工作室

  • 当前格式的DOTS可以帮助您逐步达到Unity或其他方式所无法达到的规模和性能。具体而言,更长的电池使用寿命、温度控制以及DOTS所提供的代码可重用性是其主要优势所在。这些方面的性能改进还使您可以开发更多的低端设备,尤其是在西方市场以外的地区,这些设备会受到一定的硬件限制。
  • 通过让研发团队以DOTS开展工作,可以帮助您逐步了解所能采取的最佳方法,以及哪些最新的功能和领域最具性能优势和发展影响力。
  • DOTS并非要取代引擎团队的作用,而是让工程师腾出更多精力在自己的专业领域(例如阴影或着色器)进行创新。

4.DOTS的优劣(机遇以及风险)

在改善Unity项目的绩效方面,DOTS有着巨大的潜力。 但是,在使用DOTS时需要做出一些考量,它们会影响到项目的时间表、预算和开发团队。以下是一些需要与项目优先事项进行比较和对比的事项。这些事项可以归类为风险与机遇。

机遇

  1. 改进性能。默认情况下,我们经常使用“性能”一词来描述DOTS。这是什么意思呢?借助面向数据的设计和多线程,DOTS可以显著提升内存、运行时间和电池性能。随着游戏中显示的项目数量不断增加,提高性能的潜力也随之上升。相反,对于项目较少的游戏,您会发现游戏性能的改善程度却不太明显。
  2. 代码控制。随着项目规模的不断增大,DOTS可以更好地控制代码的复杂性。为DOTS编写的代码通常可以更好地分离关注点。因此,使用DOTS工作时,代码重构、编写单元测试以及在开发人员之间分配工作就变得更加容易。

风险

  1. 学习成本。如果您不熟悉DoD,那么面对DOTS时就会有一个学习曲线。尽管DoD在计算机科学领域有着良好的根基并已存在数年,而且DoD方法与OOP方法也有很大的不同,但DoD本质上并不比OOP复杂。ECS是一种不同于当前Unity MonoBehaviour方法的代码体系架构,因此学习需要一定的时间。目前,我们认为一名普通的Unity专业开发人员平均需要1个月才能熟练使用DOTS。这一准备时间可以被使用DOTS时的代码质量和性能改进所抵消。当然,具体要取决于项目。
  2. 有限支持。DOTS当前仅与Unity中一组有限的功能兼容。 最终,DOTS将与Unity的所有功能完全兼容,但我们目前尚无实现完全兼容的时间表。不过,DOTS允许在单个项目中同时使用游戏对象和DOTS,因此您可以将DOTS用于最频繁的处理任务,而将非DOTS Unity用于其余任务。
  3. 过渡。如果之前的项目是基于Mono开发,那么跟ECS之间的转换可能比较简单,使用Unity自带的一些Hybrid工具就可以较为简单的做到,但是想要把ECS转化为目前常用的Mono的话,我们认为可以做到,但是十分困难,而且也不建议这么做(为什么要尝试把高效率转为低效率呢)。目前比较推荐的是HybridECS开发,ECS与Mono混合在一起,ECS再配合Jobsystem处理最需要多线程的那一部分。

随着时间的推移,晶体管电路逐渐接近性能极限,在摩尔定律逐渐失效的今天,人们面临的数据也呈几何倍数暴增,我们有理由去发明并且学习使用一种效率更高,更能完全发挥硬件性能的软件编程方式,目前看来也许ECS也许能做到。

二、DOTS-Man小游戏项目实战

想要熟悉DOTS以及ECS框架,最好还是要上手做一个小项目,使用部分基础组件,想要熟悉以及精通还需要大量的练习以及使用,开发过程中要配合官方Entities文档使用。
Entities最新版本0.17的官方说明文档

1.环境配置

  • 如果是Unity2020.X以下版本:
    1. windows -> package manager
    2. advanced -> show preview package
    3. install三件套 (Entities,Jobs,Burst)
    4. install其他组件(Hybrid Renderer,Mathematics)

在这里插入图片描述

  • 如果是Unity2020.X及以上版本(推荐,作者使用2020.3.26f1c1):

    • 进入package manager
    • 点击 + 号点击add package from gir url手动添加三件套以及其他组件
      • com.unity.dots.editor
      • com.unity.physics
      • com.unity.entities
      • com.unity.rendering.hybrid

    在这里插入图片描述

2.游戏设计

我们准备做一个类似Pac-Man的小游戏,主要熟悉Physics包以及Entities的基本使用,所以不会开发怪物AI之类的,因为使用DOTS开发所以就叫DOTS-MAN好了。

需求分析

主要功能有:玩家移动,镜头跟随,分数显示,因为如果用ECS来修改UGUI的TEXT可能比较麻烦,这里选择使用HybridECS开发,使用MonoBehaviours开发一些基础功能比如镜头跟随以及物体生产之类。

3.正式开发

一些自带脚本

在开发过程中,因为收集物以及玩家还有地形之类的都要有碰撞,但是ECS无法使用object上面的collider之类的组件,所以就要用Entities包自带的一些脚本。

记得在挂Entities脚本之前删掉不用的Object脚本,避免混淆以及无意义的空间占用

把Object转化成Entity的脚本:
在这里插入图片描述
一般配合一起使用的脚本就是PhysicsShape和PhysicsBody,一个控制物理碰撞的类型,一个控制entity的物理性质(例如重力之类的),各个属性的作用都有明确说明:

在这里插入图片描述

添加physicsbody之后碰到List越界报错问题解决方案:
go into YOURPROJECTLibrary/PackageCache/
copy [email protected] into YOURPROJECT/Packages/
open [email protected]\Unity.Collections\NativeList.cs
change line 599 from Allocator.None to Allocator.Invalid

Component

组件只有三个,两个存储分别存储移动和旋转的速度,一个负责标记收集物(所以里面没有数据)
要记得把Serializable属性改为GenerateAuthoringComponent,这样把component挂上object之后就会把他变成entity。
创建component和system都可以直接使用右键 -> create -> ECS进行快速选择自带模板
在这里插入图片描述

using Unity.Entities;

[GenerateAuthoringComponent]
public struct MoveComponent : IComponentData
{
    
    
    public float moveSpeed;
}
using Unity.Entities;

[GenerateAuthoringComponent]
public struct RotationComponent : IComponentData
{
    
    
    public float rotateSpeed; 
}

Component配置:
玩家:
在这里插入图片描述
墙体和收集物:

要注意在脚本中配置Collision Filter相关以及Collision Response相关,即某个entity属于哪个标签,他能与其他哪些标签的entity发生碰撞

在这里插入图片描述
搭建一个使用场景(renderer相关的根据自己喜好来整):

因为mono和ECS是相互穿插的,所以如果mono中有需要的system可以直接先去看看system的代码,配合官方文档理解为何这么做,这样才能把整个流程梳理清楚(至少我学习的时候是这样的)

Mono Behaviour

这里需要一个全局的mono behaviour来控制游戏,例如entity与object的连接,这里我们换一种方式,把之前的玩家小球弄成prefab,然后在这个全局mono控制玩家的生成,起名就叫做GameManager吧(具体说明看注释):

  1. GameManager:
using System.Collections;
using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Physics;
using UnityEngine.UI;
using Unity.Transforms;

public class GameManager : MonoBehaviour
{
    
    
    public static GameManager instance;

    public bool insaneMode;

    //在实体object世界中的prefab
    public GameObject ballPrefab;
    public GameObject cubePrefab;
    public Text scoreText;

    public int maxScore;
    public int cubesPerFrame;
    public float cubeSpeed = 3f;

    private int curScore;
    private Entity ballEntityPrefab;
    private Entity cubeEntityPrefab;
    private EntityManager entityManager;
    private BlobAssetStore blobAssetStore;

    //private bool insaneMode;

    private void Awake()
    {
    
    
        if (instance != null && instance != this)
        {
    
    
            Destroy(gameObject);
            return;
        }

        instance = this;

        //初始化EntityManager
        entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
        blobAssetStore = new BlobAssetStore();

        //从object世界获得setting
        //即inspector中可以获取的prefab
        GameObjectConversionSettings settings = GameObjectConversionSettings.FromWorld(World.DefaultGameObjectInjectionWorld, blobAssetStore);

        //通过GameObjectConversionUtility的ConvertGameObjectHierarchy来把object变成entity
        //参数(GameObject root, World dstEntityWorld)
        ballEntityPrefab = GameObjectConversionUtility.ConvertGameObjectHierarchy(ballPrefab, settings);
        cubeEntityPrefab = GameObjectConversionUtility.ConvertGameObjectHierarchy(cubePrefab, settings);

    }

    private void OnDestroy()
    {
    
    
        //重置BlobAssetStore中的blobasset缓存,释放清空blobAssetStore
        blobAssetStore.Dispose();
    }

    private void Start()
    {
    
    
        curScore = 0;

        insaneMode = false;

        //显示分数,这个函数在每一帧都会调用
        DisplayScore();
        //创建初始球球
        SpawnBall();
    }

    private void Update()
    {
    
    
        //如果符合条件就开启insanemode疯狂造方块,这里改成手动开启
        //if (!insaneMode && curScore >= maxScore)
        if (insaneMode)
        {
    
    
            //开启协程造方块
            //insaneMode = true;
            StartCoroutine(SpawnLotsOfCubes());
        }
    }

    //回调,造方块
    IEnumerator SpawnLotsOfCubes()
    {
    
    
        while (insaneMode)
        {
    
    
            //每一帧造cubesPerFrame量的方块
            for (int i = 0; i < cubesPerFrame; i++)
            {
    
    
                SpawnNewCube();
            }
            yield return null;
        }
    }


    void SpawnNewCube()
    {
    
    
        //使用entityManager造方块并且给予属性

        Entity newCubeEntity = entityManager.Instantiate(cubeEntityPrefab);

        Vector3 direction = Vector3.up;
        Vector3 speed = direction * cubeSpeed;

        PhysicsVelocity velocity = new PhysicsVelocity()
        {
    
    
            Linear = speed,
            Angular = float3.zero
        };

        //最后记得往entity添加component数据
        entityManager.AddComponentData(newCubeEntity, velocity);
    }

    public void IncreaseScore()
    {
    
    
        curScore++;
        DisplayScore();
    }

    private void DisplayScore()
    {
    
    
        scoreText.text = "Score: " + curScore;
    }

    //造第一个球
    void SpawnBall()
    {
    
    
        Entity newBallEntity = entityManager.Instantiate(ballEntityPrefab);

        Translation ballTrans = new Translation
        {
    
    
            //初始位置
            Value = new float3(0f, 0.5f, 0f)
        };

        //还是要记得添加component
        entityManager.AddComponentData(newBallEntity, ballTrans);
        //设置镜头跟随的对象
        CameraFollow.instance.ballEntity = newBallEntity;
    }
}
  1. CameraFollow:
    相机跟随的mono脚本:
using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;
using UnityEngine;

public class CameraFollow : MonoBehaviour
{
    
    
    public static CameraFollow instance;

    public Entity ballEntity;
    //设置一个偏移量用来调整相机位置
    public float3 offset;

    private EntityManager manager;

    private void Awake()
    {
    
    
        if (instance != null && instance != this)
        {
    
    
            Destroy(gameObject);
            return;
        }

        instance = this;
        manager = World.DefaultGameObjectInjectionWorld.EntityManager;
    }

    private void LateUpdate()
    {
    
    
        if (ballEntity == null) {
    
     return; }

        Translation ballPos = manager.GetComponentData<Translation>(ballEntity);
        transform.position = ballPos.Value + offset;
    }
}

记得把相机脚本挂到main camera上!

System

  1. MoveSystem:
    控制玩家移动,获取玩家输入放入一个float2中,具体的Mathematics相关class可以看官方文档,这是一个用起来比vector要快的东西(因为ECS是数据驱动,不用特别关注object):
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Physics;
using UnityEngine;

public class MoveSystem : SystemBase
{
    
    
    protected override void OnUpdate()
    {
    
    
        float deltaTime = Time.DeltaTime;

        float2 curInput = new float2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));

        Entities.ForEach((ref PhysicsVelocity vel, ref MoveComponent speedData) =>
        {
    
    
            float2 newVel = vel.Linear.xz;

            newVel += curInput * speedData.moveSpeed * deltaTime;

            vel.Linear.xz = newVel;
        }).Run();
    }
}

相关要点:
ForEach就是对包含参数相关Component的entity在每一帧都进行一定的操作,其中ref关键字表示对数据进行读取也可以修改,而in关键字表示对数据只读,而且in一定要全部放在ref后面。
后面的的.Run()表示在主线程中运行,如果要在子线程可以使用Schedule。

  1. RotateSystem:
    控制收集物旋转的system,具体的quaternion用法可以参考官方文档:
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;

public class RotateSystem : SystemBase
{
    
    
    protected override void OnUpdate()
    {
    
    

        float deltaTime = Time.DeltaTime;

        Entities.ForEach((ref Rotation rotation, in RotationComponent rotationSpeed) =>
        {
    
    
            rotation.Value = math.mul(rotation.Value, quaternion.RotateX(math.radians(rotationSpeed.rotateSpeed * deltaTime)));
            rotation.Value = math.mul(rotation.Value, quaternion.RotateY(math.radians(rotationSpeed.rotateSpeed * deltaTime)));
            rotation.Value = math.mul(rotation.Value, quaternion.RotateZ(math.radians(rotationSpeed.rotateSpeed * deltaTime)));
        }).Run();
    }
}

记得这时候往你的object上面挂component!如果想让玩家移动就挂movecomponent,让收集物旋转就挂上rotationcomponent。可以想一想,如果你往收集物上挂了movecomponent会发生什么?为什么会这样?

这时候你的收集物应该是旋转的,玩家小球可以通过wasd或者方向键控制移动:
在这里插入图片描述
3. CollectSystem:
然后就是最难的碰撞收集系统了!本来在mono中两三行就可以解决的问题,现在要写几十行才能解决!但是对于后期优化以及性能上的提升,这些困难都不算什么!
相关的解释说明都在注释中了:

using Unity.Entities;
using Unity.Collections;
using Unity.Physics;
using Unity.Physics.Systems;

[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
public class CollectSystem : SystemBase
{
    
       
    //用bufferSystem来处理这些碰撞事件
    private EndFixedStepSimulationEntityCommandBufferSystem bufferSystem;
    //初始化entity的物理世界
    private BuildPhysicsWorld buildPhysicsWorld;
    private StepPhysicsWorld stepPhysicsWorld;

    protected override void OnCreate()
    {
    
    
        bufferSystem = World.GetOrCreateSystem<EndFixedStepSimulationEntityCommandBufferSystem>();
        buildPhysicsWorld = World.GetOrCreateSystem<BuildPhysicsWorld>();
        stepPhysicsWorld = World.GetOrCreateSystem<StepPhysicsWorld>();
    }

    protected override void OnUpdate()
    {
    
    
        //每一帧都添加一个triggerjob来进行碰撞判断,因为需要判断的是有MoveComponent的玩家
        //以及有DeleteTag的收集物,所以就要在job中进行选择
        Dependency = new TriggerJob
        {
    
    
            speedEntities = GetComponentDataFromEntity<MoveComponent>(),
            entitiesToDelete = GetComponentDataFromEntity<DeleteTag>(),
            commandBuffer = bufferSystem.CreateCommandBuffer(),
        }.Schedule(stepPhysicsWorld.Simulation, ref buildPhysicsWorld.PhysicsWorld, Dependency);

        //把job传递到buffer中
        bufferSystem.AddJobHandleForProducer(Dependency);

    }


    //创建一个triggerjob来进行碰撞处理
    private struct TriggerJob : ITriggerEventsJob
    {
    
       
        //初始化处理的entity
        public ComponentDataFromEntity<MoveComponent> speedEntities;
        [ReadOnly] public ComponentDataFromEntity<DeleteTag> entitiesToDelete;
        public EntityCommandBuffer commandBuffer;

        public void Execute(TriggerEvent triggerEvent)
        {
    
    
            TestEntityTrigger(triggerEvent.EntityA, triggerEvent.EntityB);
            TestEntityTrigger(triggerEvent.EntityB, triggerEvent.EntityA);
        }

        //处理碰撞,如果被碰撞的物品没有DeleteTag,就把DeleteTag挂上去,移除它的物理组件
        private void TestEntityTrigger(Entity entity1, Entity entity2)
        {
    
    
            if (speedEntities.HasComponent(entity1))
            {
    
    
                if (entitiesToDelete.HasComponent(entity2)) {
    
     return; }
                commandBuffer.AddComponent<DeleteTag>(entity2);
                commandBuffer.RemoveComponent<PhysicsCollider>(entity2);
            }
        }
    }
}
  1. DeleteSystem:
    控制删除有deletetag的entity的system:
using Unity.Entities;

[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
//这里添加一个属性,就是在collectionsystem发生之后再更新,因为要先碰撞之后再进行处理
[UpdateAfter(typeof(CollectSystem))]
public class DeleteSystem : SystemBase
{
    
    
    private EndFixedStepSimulationEntityCommandBufferSystem _endSimulationECBSystem;

    protected override void OnStartRunning()
    {
    
    
        _endSimulationECBSystem = World.GetOrCreateSystem<EndFixedStepSimulationEntityCommandBufferSystem>();
    }

    protected override void OnUpdate()
    {
    
    
        var ecb = _endSimulationECBSystem.CreateCommandBuffer();

        Entities
            .WithAll<DeleteTag>()
            .WithoutBurst()
            .ForEach((Entity entity) =>
            {
    
        
                //修改分数
                GameManager.instance.IncreaseScore();
                ecb.DestroyEntity(entity);
            }).Run();
        _endSimulationECBSystem.AddJobHandleForProducer(Dependency);
    }
}

这里ForEach之前有一系列限定条件,比如.WithAll()的意思就是对带有deletetag的entity执行下面的操作,这样能更加方便的进行处理,所以大部分情况下entity都会被打一个标签来区别其他entity

最后需要进行的一些操作

  • 创建一个空物体放入GameManager,并且进行相关配置(可以在play模式下打开insaneMode看看ECS的强大性能提升):
    -
  • 在play模式下调整Camera中的相机跟随参数,让镜头舒服:

在这里插入图片描述

太棒啦!

太棒啦!你成功的使用了目前领先的开发模式开发了一个小游戏,虽然这个小游戏的功能在mono中实现的话可以很简单的实现,但是随着工程规模的扩大以及性能需求的提高,ECS只会愈发强大!因为目前DOTS相关教程不完善,所以如果在上述开发中碰到问题主要需要参考官方文档以及一些论坛大牛的解答,想要更深入的理解还需要更多项目的磨练。

3. 参考文档

Entities最新版本0.17的官方说明文档:
https://docs.unity3d.com/Packages/[email protected]/api/Unity.Entities.html
油管ECS大神Turbo的说明文档:
https://www.tmg.dev/tuts/roll-a-ball-entities-0-17/

猜你喜欢

转载自blog.csdn.net/qq_51773145/article/details/123358623
今日推荐