前言
本文是前篇《十分钟上手Unity ECS》的续作,着重对高效与性能的特点进行讲解。不管是接触过Unity的ECS,想要继续深入了解;或者是没接触过但想了解这部分高性能的一些原理,都不妨碍阅读本文。
跟前篇一样,本文将围绕一个小Demo,对比说明ECS的方式与旧版实现方式在编程思想以及代码编写上的特点和原因。
Demo内容
球球雨——2w个球自由落体,掉到底就回到顶部,如此不断循环,像下雨一样。大概是这个样子:
传统做法
写个循环,生成2w个GameObject,给每个对象加上MeshFilter、MeshRenderer还有一个自己实现的自由落体组件。比较简单,不做过多解释了,代码如下:
自由落体组件
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class LagacyDrop : MonoBehaviour
{
public float mass;
public float delay;
public float velocity;
void Update()
{
if (this.delay > 0)
{
this.delay -= Time.deltaTime;
}
else
{
Vector3 pos = transform.position;
float v = this.velocity + GravitySystem.G * this.mass * Time.deltaTime;
pos.y += v;
if (pos.y < GravitySystem.bottomY)
{
pos.y = GravitySystem.topY;
this.velocity = 0f;
this.delay = Random.Range(0, 10f);
}
transform.position = pos;
}
}
}
生成对象的代码片段(spawnCount = 20000)
void StartLagacyMethod()
{
var rootGo = new GameObject("Balls");
rootGo.transform.position = Vector3.zero;
for (int i = 0; i < spawnCount; ++i)
{
var go = new GameObject();
var meshFilter = go.AddComponent<MeshFilter>();
meshFilter.sharedMesh = mesh;
var meshRd = go.AddComponent<MeshRenderer>();
meshRd.sharedMaterial = mat;
var dropComponent = go.AddComponent<LagacyDrop>();
dropComponent.delay = 0.02f * i;
dropComponent.mass = Random.Range(0.5f, 3f);
Vector3 pos = UnityEngine.Random.insideUnitSphere * 40;
go.transform.parent = rootGo.transform;
pos.y = GravitySystem.topY;
go.transform.position = pos;
}
}
运行结果:
PC配置为Ryzen5 1600 + 16G DDR4 + GTX1070,只跑到了30帧,而且一般只有20+
ECS做法
- 实现自由落体组件、自由落体系统
- 使用批量接口创建实体,并给实体添加、设置自由落体组件、位置组件、Mesh渲染器,其中位置组件以及Mesh渲染器是Unity的ECS中内置组件
结合代码讲解:
自由落体组件
using Unity.Entities;
[System.Serializable]
public struct GravityComponentData : IComponentData
{
public float mass;
public float delay;
public float velocity;
}
public class GravityComponent : ComponentDataWrapper<GravityComponentData> { }
- ComponentDataWrapper/SharedComponentDataWrapper : 对应组件为IComponentData/ISharedComponentData的一个包装。用途:把一个struct包装成MonoBehavior,然后就可以添加到prefab上了。原理:Wrapper的基类继承自MonoBehavior,拥有一个泛型T的成员,即要包装的组件。
- 注意类名要跟文件名一致,否则没法在Editor编辑prefab的时候添加上去。
自由落体系统
using Unity.Entities;
using Unity.Transforms;
using UnityEngine;
public class GravitySystem : ComponentSystem
{
struct Filter
{
public readonly int Length;
public ComponentDataArray<GravityComponentData> gravity;
public ComponentDataArray<Position> position;
}
[Inject] Filter data;
public static float G = -20f;
public static float topY = 20f;
public static float bottomY = -100f;
protected override void OnUpdate()
{
for (int i = 0; i < data.Length; ++i)
{
var gravityData = data.gravity[i];
if (gravityData.delay > 0)
{
gravityData.delay -= Time.deltaTime;
data.gravity[i] = gravityData;
}
else
{
Vector3 pos = data.position[i].Value;
float v = gravityData.velocity + G * gravityData.mass * Time.deltaTime;
pos.y += v;
if (pos.y < bottomY)
{
pos.y = topY;
gravityData.velocity = 0f;
gravityData.delay = Random.Range(0, 10f);
data.gravity[i] = gravityData;
}
data.position[i] = new Position() { Value = pos };
}
}
}
}
测试类
using Unity.Collections;
using Unity.Entities;
using Unity.Rendering;
using Unity.Transforms;
using UnityEngine;
public class BallDropMain : MonoBehaviour
{
public Material mat;
public Mesh mesh;
public GameObject ballPrefab;
public int spawnCount = 5000;
void Start()
{
//StartLagacyMethod();
StartECSMethod();
//StartECSMethod(ballPrefab);
}
void StartECSMethod(GameObject prefab = null)
{
var entityMgr = World.Active.GetOrCreateManager<EntityManager>();
var entities = new NativeArray<Entity>(spawnCount, Allocator.Temp);
//Two ways of creating entities
if (prefab)
{
entityMgr.Instantiate(prefab, entities);
}
else
{
var archeType = entityMgr.CreateArchetype(typeof(GravityComponentData), typeof(Position), typeof(MeshInstanceRenderer));
entityMgr.CreateEntity(archeType, entities);
}
var meshRenderer = new MeshInstanceRenderer()
{
mesh = mesh,
material = mat,
};
//Add Components
for (int i = 0; i < entities.Length; ++i)
{
Vector3 pos = UnityEngine.Random.insideUnitSphere * 40;
pos.y = GravitySystem.topY;
var entity = entities[i];
entityMgr.SetComponentData(entity, new Position { Value = pos });
entityMgr.SetComponentData(entity, new GravityComponentData { mass = Random.Range(0.5f, 3f), delay = 0.02f * i });
entityMgr.SetSharedComponentData(entity, meshRenderer);
}
entities.Dispose();
}
void StartLagacyMethod()
{
var rootGo = new GameObject("Balls");
rootGo.transform.position = Vector3.zero;
for (int i = 0; i < spawnCount; ++i)
{
var go = new GameObject();
var meshFilter = go.AddComponent<MeshFilter>();
meshFilter.sharedMesh = mesh;
var meshRd = go.AddComponent<MeshRenderer>();
meshRd.sharedMaterial = mat;
var dropComponent = go.AddComponent<LagacyDrop>();
dropComponent.delay = 0.02f * i;
dropComponent.mass = Random.Range(0.5f, 3f);
Vector3 pos = UnityEngine.Random.insideUnitSphere * 40;
go.transform.parent = rootGo.transform;
pos.y = GravitySystem.topY;
go.transform.position = pos;
}
}
}
ativeArray:看名字应该是C++层提供的数组,官方说更高效,实际用起来也确实是。但因为不会被GC,所以用起来必须很小心,用完要主动Dispose掉。Archetype:大概译作“原型”吧,可以理解为实体的“模板”,它表示包含了多个类型,是实体的数据结构的描述。那么在创建实体,为实体分配内存的时候就会分配大小合适的内存创建实体的方式:一般是两个接口,EntityManager.CreateEntity 和 EntityManager.Instantiate,都支持NativeArray的方式初始化,InstantiatePosition和MeshInstanceRenderer:Unity为ECS内置了几套轻量级类库,让实体可以脱离GameObject,比如可以有位置、旋转、网格和渲染器但是没有transform、rigidbody关于[Inject]:必须Inject到struct里,struct可以看作是一个过滤器,把你关注的实体组件筛选出来。注意虽然一个struct里存在多个数组,但每个数组长度是一样的,每个数组相同下标的组件其实都属于同一个实体,所以可以声明一个readonly的int成员(当然你也可以用数组的.Length),单独放统一的长度。
挂启动脚本的对象
球的prefab
运行结果
所以对比可以看出,不开启GPU Instancing的情况下,性能热点在于CPU提交DrawCall,所以用不用ECS帧率都一样;而开启以后性能热点转移到球体运动的计算上,使用了ECS的Demo展现出了它的优势。
理解ECS框架
- ArcheType和Chunk
ArcheType代表了实体的类型,同时创建实体时分配的内存块我们叫做chunk,正式来说chunk是属于ArcheType的,这个概念很重要,请记住它。
举个例子,当前已有一个实体,ArcheType由A、B两个组件类型组合而成,而这个实体当前关联了一个chunk。当给实体添加组件C时,会生成一个新的ArcheType,而且重新分配一个chunk关联到这个新的ArcheType。这点也请记住,跟下面说的效率提升密切相关。
- 顺序的内存布局使得CPU访问效率的提升
这点大家都应该或多或少的知道一些,首先CPU有一级二级3J缓存,离CPU越近访问速度越快,我们这里先忽略一二3J的细节以及淘汰策略之类的问题,统一当成是一个缓存。在访问某段内存时,由于优化策略,会把相邻的内存也顺道载入缓存。所以如果下一条CPU指令命中了缓存里的数据,就不需要重新到内存里取数据,速度提升非常巨大。
基于以上原理,ECS的内存布局都设计成顺序的以针对CPU缓存命中来做优化。比如上面的例子,我们创建了2w个实体,内存布局大概是这个样子的:
本人PC上GPU Instancing与ECS的组合测试结果
所以当遍历ComponentDataArray的时候,所有内存访问都会命中缓存。
而官方对ComponentDataArray也有解释,其实是个惰性求值的迭代器,它直接指向内存中存放chunk的地址,通过下标计算对应组件的偏移值。但是官方并没有明确说明如果创建的实体他们的ArcheType有子集或者交集的时候会怎么处理或者怎么优化访问速度。
- 数据与接口分离,面向数据与切面编程
面向对象(Object-Oriented)的缺点:继承是面向对象编程的三大特征(抽象、继承、多态)之一,同时也是面向对象的一大缺点,它导致我们可能会继承一些无用的数据,浪费内存,内存命中率低下。而且由于面向对象的设计在大多数情况下根本做不到完美的类层次划分,即使一时满足了设计也会被软件迭代的一次次“攻击”下,变得笨重不堪。因此,ECS框架的开发团队认为,应该放弃过去的面向对象编程方式,用面向数据编程来进行连续紧凑的内存布局,以提高内存命中率。
ECS框架的思想:关注数据类型,关注系统的运行和状态,不关注具体某个对象的细节。所以ECS也不是万能的,适用前提是你关注点偏向宏观,并且需要处理大量同类的对象。如果你关注的是细节,并且细节交互非常细腻复杂,没有大量同类对象,那可能还是面向对象更适合一些。