CPU与缓存(Cache)
首先我们先来了解一下CPU读取数据时的操作,首先CPU会先从自己的缓存中去查找,如下图,有L1/ L2/ L3三级缓存,若缓存中没有找到需要的数据,则会去内存中查找(我们称之为Cache Miss),CPU读取到内存数据后就会将新数据存放在缓存当中。CPU访问内存的速度会比访问L1 Cache的速度慢100倍,因此提高缓存命中率(Cache Hit),避免Cache Miss会大大提高性能。因此我们应该尽量使用数组,尽量分割属性(SOA),尽量连续的进行处理。
这也使得一味的讨论复杂度O(n)不再适用,因为现在效率=数据+代码,最常见的例子就是在数据量小的情况下遍历数组会比 (Hash)Map 快上很多
缓存行
缓存又由若干个缓存行(cache line)组成,每个缓存行大概占64字节。
假设我们现在想要旋转并移动场景中的一个物体,那么我们会修改它的Position(Vector3数据,3个float)和Rotating(Quaternion,4个float),其中1个float占4字节,那么一共占28字节,那么在这个缓存行就会有36个字节是完全浪费的,甚至我们可能只改了Position的x的值,这样浪费的就更多。若有上千个这样的方块,那么我们缓存中可能就会存在超50%的内存垃圾,从而导致缓存命中率大大的降低。
问题的引出
了解了上面的知识之后,我们再看回Unity,在传统模式下,我们在场景中创建一个Cube,上面会有Transform,MeshRenderer,Collider等组件,而这些组件在内存中的排放都是无序的,这就会降低我们的缓存命中率。
除此以外,像我们前面说到的旋转移动方块,我们只用到了Position和Rotating两个属性,但是使用的时候整个Transform都会被加到缓存当中,而Transform中有很多我们不需要的属性占用了不少的缓存空间,同样的降低了我们的缓存命中率。
使用ECS就可以解决上述的这些问题,从而提高性能。
ECS概念
ECS即Entity Component System,是Unity的一种框架,我们可以把Entity看做是一个唯一id,Component看做是数据,其本质是一个存放数据的Struct,一个Entity上可以拥有多个Component,但是Component中不会有任何逻辑处理。而System则可以根据Entities索引读取对应的Component,对Component中的数据进行处理。
如上图,有三个Entity,Entity A,Entity B,Entity C,与它们相关联的Translation,Rotation,LocalToWorld和Renderer这些都是Component。同时还有一个System,用来将Translation和Rotation两个Component内的值相乘并赋予LocalToWorld。
我们可以设置System需要Renderer Component,那么Entity C将被该System忽略。或者设置System处理的Entity不能有Renderer Component,那么Entity A和Entity B将被忽略。
Archetypes
不同的Component可以有多种不同的组合,每种组合我们即称之为Archetype。例如下图,Entity A和Entity B的Component的组合是相同的,所以他们都属于Archetype M,而Entity C由于少了Renderer,所以是另一种组合,属于Archetype N。
由于可以在运行时给Entity动态的添加或删除Component,若我们将Entity A的Renderder删除,则此时Entity A会隶属于Archetype N,若再删除Rotation,则剩下的Component组合和Archetype M,Archetype N都不相同,就好隶属于一个新的Archetype。
Memory Chunks
ECS会根据Archetype来进行分配内存,每个内存块我们称之为Chunk,ECS会将符合Chunk对应Archetype的Entity放在该Chunk当中。一个Chunk中,内存地址是连续的,大小固定为16KB。若Chunk装满了,则会生成一个新的Chunk用来存储新生成的且Archetype符合的Entity。
由于我们动态的添加或删除Entity的Component,会导致其Archetype变化,因此ECS也会改变其Chunk,放到与之对应Chunk中。
可能描述的不太好,我们来看示例图。下图中有三种Archetype,分别对应三种Component组合。每个Archetype都会有对应的Chunk用来存储对应的Entity。若Chunk存储满了,就会在对应Archetype下新生成一个Chunk。
这样的设计理念使Archetypes and Chunks是一对多的关系,同时若给定一个Component组合,我们要找到所有对应的Entity,只需要搜索现有的archetype即可,而不需要遍历所有的Entity。
ECS不支持使用特殊的排序来将Entity存储进Chunk中,若有一个Entity被创建或者被改变,使其隶属于一个新的Archetype时,ECS会将其存储在该Archetype下第一个还有空间的Chunk中。若有一个Entity被从Chunk中移除,ECS则会把该Chunk中最后一个Entity与其对应的Component移到这个空缺的位置中。
举例
关于Entity,Component,Archetype和Chunk的关系,我们在此举个简单的例子。假设我们有两个Component:C1和C2,然后我们生成五个Entity,其对应的Component分别为:E1(C1,C2),E2(C1),E3(C1,C2),E4(C1,C2),E5(C2)。由于组合分别有C1,C2,C1C2三种,所以会有三个Archetype,假设我们一个Chunk只能存储两个Entity。那么最终结果为:
Archetype1:Chunk1 [ E1(C1,C2),E3(C1,C2) ] -> Chunk2 [ E4(C1,C2) ]
Archetype2:Chunk1 [ E2(C1) ]
Archetype3:Chunk1 [ E5(C2) ]
结合到我们前面有关缓存的知识,若我们要移动选择一个方块,可能会写两个Component,MoveComponent存x和y两个float(假设只前后左右移动),RotateComponent只存y一个float(假设只旋转y轴),这样我们的方块就只会有3个float,占用12个字节。若有多个相同的方块,由于都有上述两个Component,也就是属于同一种Archetype,因此内存也是连续的。那么在一个64字节的缓存行中,我们可以放下5个这样的方块数据,只有4字节是浪费的,就会大大降低Cache Miss。