Unity ECS

作者:庞巍伟
链接:https://www.zhihu.com/question/286963885/answer/452979420
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
 

ECS解决2个问题:

1)性能;

2)减少不必要的内存使用;

放一张图,之前写了demo测试,对于使用ecs,不使用ecs,做instancing优化3中情况下,性能的差别。

可以看到如果你渲染的object在500以内,ecs性能并没有显著提升,当超过1000后,ecs性能有显著的优势,在10000obj下,差不多100的性能差距。

所以对于200内obj的游戏,是不是用ecs差别不大。

另外ecs这是unity提出的一个系统化的方案和标准,我们自己也可以或多或少使用传统方法做出类似的结果,没必要非ecs不可。

demo是如下图(Instancing),根据自带的rotate demo完成对应的instancing和传统方法版本,这个demo是1000个cube,有一个sphere旋转,撞到cube后,cube会自转一段时间,逐渐停止,所以需要1001个物体不停的update:

=========补充公司内分享的完整文章:

不再需要MonoBehaviour、Component和GameObject

以前MonoBehaviour承载了游戏逻辑和数据两部分功能,我们通过创建GameObject然后添加MB(MonoBehaviour,下同)然后通过Update来更新游戏逻辑,往往我们Update里就是更新一些数据,而MB的实现非常复杂,有很多不需要的功能都一股脑的被继承下来,这导致我们一个非常简单的任务可能需要浪费很多内存去应付那些不需要的功能,如果再无节制的使用MB,那基本就是游戏运行效率的噩梦。

之前的Component是继承自MB,大部分时候Component的作用就是提供数据,但是通过Component组织的数组是对CPU cache不够友好的,因为它并没有把需要多次重复计算更新的数据组织到一起,使得CPU在做计算时可能cache miss,如果游戏逻辑需要大量对象需要更新数据,可能这部分消耗是非常大的。

同时MB不能很好的解决执行顺序问题,对于动态创建的GameObject,MB的更新顺序是不确定的,我们往往系统某些MB的Update和Destroy在某些MB之后,以保证相互的依赖关系,但Unity的MB设计上没有更好的解决这个问题,通过调整Script Execution Order既麻烦也没啥卵用(之前测试对于动态创建的MB毫无作用,只能用于场景中静态的MB)。

还有,如果游戏中存在大量的GameObject,上面绑定大量的Component,那么执行大量的Update是非常耗时的,而且这些Update只能运行在主线程,无法并行。

为此,Unity 2018.2 引入了全新的ECS系统,用于解决上面提到的问题。

全数据化驱动

ECS的核心设计目标就是去掉传统的MB,GameObject,Component对象结构,而改为全数据化驱动的代码组织方式,通过这样的改变,可以解决2个问题:

1)将数据更有效的组织,提高CPU cache利用率;

2)并行化。

先简单看一个例子,这个是ECS sample自带的例子:

这个例子可以看到,虽然画面里有超过1000个物体,但并没有对应1000个GameObject,当球体碰到方块的时候,会产生自转后衰减,同时可以保持在300-600的fps运行帧率。这在以前,我们要实现类似的效果需要创建1000个GameObject,然后有1000个MB负责更新GameObject的transform信息,我按照这样的方法来实现,那么这个demo大概只有1/3的fps。

注意到上图会创建更多的GameObject,fps大概100-200fps之间,当然这么实现并不是最优化的,我们还可以使用Instancing优化drawcall,为了对比我又实现了Instancing的版本,对比如下:

fps大概150-300fps,可以看到instancing大概提高了1倍的fps,在1000 objs测试下,不同实现方法之间差别大概1-2倍,貌似差别不是很大,于是我又测试了更高obj数量的fps,在更高的objs测试下,得到如下图表:

可以看到在更高的obj数量下,ECS方法的优势就体现出来了,在10000 obj下,ECS任然可以做到350的高fps,而即便Instance优化,也只剩下4fps,差距几乎100倍。

现在我们回到开头,ECS解决了如下2个问题:

1)将数据更有效的组织,提高CPU cache利用率;

2)并行化。

但是如何解决的呢?

将数据更有效的组织,提高CPU cache利用率

传统的方法是将gameobject的数据放在Components上,比如可能是这样(参考1):

using Unity.Mathematics;
class PlayerData// 48 bytes
{
 public string public int[]
 public quaternion
 public float3
 string name;       // 8 bytes  
 someValues; // 8 bytes
}
PlayerData players[];

他的内存布局是这样:

而这样的设计对cpu cache是极度不友好的,当你试图批量更新float3数据时,cpu cache预测总是失败的,因为他们被分配到了内存中不连续的区域,cpu需要几次寻址跳转才能找到对应的数据并操作,如果我们能够将需要批量更新的数据连续的存放到一起,这将大大提高cpu cache的命中率,提升计算速度。

按ECS设计规范,就是将批量的更新的数据抽取出来,在内存中连续排列,从而在cache预读中能够将后续的数据一次性读取进来,提高cpu操作的效率,如图(参考1):

在实际计算的时候,通过间接索引,来更新全部entity的各个类型的数据,而不是更新每个entity的全部数据,如图:

可以看到这里,最大的变化是:

在一个system(相当于以前的component)更新计算中,是将所有的entity的position放在一起批量更新的,因为这些position的float3数据在内中是连续的,cpu操作起来是最快的。

并行化

将数据单独提取出来后,接下来的任务就是将这些数据计算并行化,充分利用多核。在以前,几乎逻辑代码都是跑在主线程,当然也有项目组意识到了这个问题,会将一些和显示无关的逻辑放入多线程中并行,但都没有彻底的在数据上抽象形成一套完整的开发框架,而ECS解决了这个问题。

ECS开放了底层的job system系统,在上层提供了c# job system,它是一套多线程调度系统。如果不同的数据是无相互依赖的,仅需要将这些数据通过c# job system放入多个线程并行化计算就可以了,如:

public class RotationSpeedSystem : JobComponentSystem
    {
        struct RotationSpeedRotation : IJobProcessComponentData<Rotation, RotationSpeed>
        {
            public float dt;

            public void Execute(ref Rotation rotation, [ReadOnly]ref RotationSpeed speed)
            {
                rotation.Value = math.mul(math.normalize(rotation.Value), quaternion.axisAngle(math.up(), speed.Value * dt));
            }
        }

        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            var job = new RotationSpeedRotation() { dt = Time.deltaTime };
            return job.Schedule(this, 64, inputDeps);
        } 
    }

如果不同的数据有依赖,需要其他的数据计算完才能完整计算,则可以设置任务依赖,c# job system会自动完成这样任务的调用和依赖关系排序。

混合模式

目前已经存在大量的传统方式开发的代码,如果想享受到ECS带来的高效,势必要将现有代码大幅改造,有没有相对简单的方法既能保持现有代码没有太大变动,又可以提高效率呢,答案是ECS提供一种兼容的混合模式。

例如如下代码(参考2):

using Unity.Entities;using UnityEngine;
class Rotator : MonoBehaviour{
 // The data - editable in the inspector public float Speed;
}
class RotatorSystem : ComponentSystem{
 struct Group
    {
 // Define what components are required for this  // ComponentSystem to handle them. public Transform Transform;
 public Rotator   Rotator;
    }

 override protected void OnUpdate()
 {
 float deltaTime = Time.deltaTime;

 // ComponentSystem.GetEntities<Group>  // lets us efficiently iterate over all GameObjects // that have both a Transform & Rotator component  // (as defined above in Group struct). foreach (var e in GetEntities<Group>())
        {
            e.Transform.rotation *= Quaternion.AngleAxis(e.Rotator.Speed * deltaTime, Vector3.up);
        }
    }
}

主要的修改是把MB的Update函数移到了ComponentSystem的OnUpdate函数中,同时增加了一个Group的struct,用于在MB和ComponentSystem之间交换数据,同时在原GameObject上添加一个GameObjectEntity组件,这个组件的用途是将GameObject其他全部的Component抽取出来并创建一个Entity,这样就可以通过GetEntities函数在ComponentSystem中遍历对应的GameObject了。

Unity会在启动的时候,把所有挂载了GameObjectEntity组件的GameObject,都创建对应ComponentSystem,这样你任然可是使用以前的GameObject.Instantiate方法来创建GameObject。只是原本MB的Update函数被替换到了ComponentSystem的OnUpdate函数中。

通过这样的修改,你可以混合ECS的部分能力又相对保持原本的代码结果,总结来说:

混合模式可以分离数据和行为,可以批量化更新对象的数据,避免每个对象的virtual方法调用(抽象到了一个函数中),任然可以继续使用inspector来观察GameObject的属性和数据。

但是这样修改并没有彻底改善什么,创建、加载的时间没有改善,数据的访问任然是cache不友好的,并没有把数据在内存中连续排布,也没有并行化。

只能说这样修改是为了进一步靠近纯ECS做的阶段性代码重构,最终还是要完全使用纯ECS才能获得最大的性能提升。

相关参考

1)Unity 官方分享ppt,ECS & Job System。

2)https://github.com/Unity-Technologies/EntityComponentSystemSamples/

猜你喜欢

转载自blog.csdn.net/Momo_Da/article/details/112906063
ECS
今日推荐