news 2026/9/14 5:02:32

Unity DOTS深度解析:从Entity到System,彻底搞懂ECS核心机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity DOTS深度解析:从Entity到System,彻底搞懂ECS核心机制

写这篇文章之前,先说明一下我的背景。我从Unity 2018年开始尝试DOTS,当时还是ECS 0.0.12的远古版本,API几乎两天一变,写完了代码睡一觉起来就看不懂了。中间断断续续弃坑过好几次,直到Entities 1.0正式版发布,DOTS才真正具备了在生产项目里落地的条件。这个系列前面几篇讲了Job System和Burst,这篇就集中火力把Entities本身讲透,也就是ECS三件套里的“E”和“C”怎么组织数据,“S”怎么写才不踩坑。

如果你正准备用DOTS做大规模同屏单位、开放世界物件管理,或者纯粹想感受一下数据导向设计带来的性能碾压,这篇就是为你准备的。

1. 为什么ECS值得你放下熟悉的MonoBehaviour

先别急着写代码,把思考方式掰过来比什么都重要。很多人接触ECS第一反应是“这不就是组件模式吗,我早就用过了”,大错特错。ECS和MonoBehaviour开发是两套完全相反的思维方式,差别大到一言难尽。

1.1 传统OOP模式的问题到底出在哪

MonoBehaviour时代,每个GameObject上都挂着若干组件,逻辑分散在这些组件里,数据也散落在各自组件里。假设你有一万个敌人,它们都带有一个MoveComponent,每个移动组件里存着速度、方向、目标点。从CPU的角度看,这些数据在内存里的分布是不连续的,中间夹杂着其他组件的数据、甚至已经被销毁的对象的残留数据。

CPU访问内存是按缓存行(Cache Line,通常是64字节)读取的。当你要遍历一万个MoveComponent时,传统组件模式需要频繁地把不同的缓存行换入换出,而每个缓存行里可能只有一小部分是你需要的。这种情况的专业术语叫“Cache Miss”,它的代价是CPU空转几个时钟周期等内存数据。一万个对象累积起来,性能损耗相当可观。

另一个问题是单线程。MonoBehaviour的Update、FixedUpdate这些回调基本都由主线程串行执行。虽然Unity之后加入了Job System来做多线程,但MonoBehaviour数据之间的依赖关系错综复杂,你很难安全地把它们拆到多个工作线程里去跑。

还有一个隐性问题:每次访问GameObject组件,都要经过一次托管层的封装调用。比如通过GetComponent<T>()拿到的是一个包装过的对象,这个操作本身就有额外开销。当你在循环里重复调用时,这部分开销会被放大得非常明显。

1.2 数据导向设计:让内存布局给CPU打工

ECS换了一整套思路。它不关心对象,关心的是“组件类型”。Entity只是一个整数ID,相当于一个数据库主键。MoveSpeed这种纯数据组件被提取出来,按照类型整齐地排列在连续内存里。当你在System里遍历一万个移动组件时,CPU是顺着连续内存一条道读到尾的,缓存命中率提升到一个离谱的水平。

官方有一个很经典的测试数据:一万个旋转Cube,在MonoBehaviour下跑到30帧左右,换成ECS + Burst之后,即使数量翻到十万个还能跑满60帧。这个差距不是10%或20%,而是几十倍的性能跃迁,根本原因是数据存放方式变了,而不是什么黑魔法。

再配合力推的多线程架构。System与System之间按依赖关系排列,但每个System内部的逻辑数据访问是明确声明过的(哪些组件只读、哪些可写),这就让Job System能够极其安全地把它们分配到多个工作线程上并行执行,而不会产生数据竞争。解决了单线程瓶颈,ECS的伸缩性就很自然地建立起来了。

提示:理解CS的本质突破点就一句话:把“对象思维”换成“数据思维”——先想数据怎么组织和流动,再想逻辑怎么处理这批数据。只要这个转变完成了,后面写代码就是熟练工种。

2. Entities核心概念与开发环境准备

正式开始写代码之前,有几个前置概念需要先建立起来。这一块如果没弄清楚,后边你会在各种编译报错里怀疑人生。

2.1 包版本选择与安装

Entities是独立于Unity主编辑器发布的包,需要通过Package Manager来安装。打开Window -> Package Manager,左上角选择Unity Registry,搜索Entities,安装即可。当前稳定版本线是1.0.x系列。

版本选择是个关键决定。如果你在网上搜DOTS教程,可能会看到大量的EntityManager.CreateEntityComponentSystem[Inject]之类的旧API,那都是0.x时代的老古董了,现在已经全部废弃,照着抄是编译不过的。我现在使用的搭配是Unity 2022.3 LTS + Entities 1.0.14,整体已经非常稳定。

安装包时有一个细节:默认情况下Entities会把你项目里所有的GameObject都当成潜在的生成源,但如果你还没配置好Baker相关代码,它不会自动转换任何东西,所以不用担心装完包后场景空物体被吃掉。不过要注意,如果项目中同时使用了Havok Physics或Netcode等DOTS生态包,需要确认它们与当前Entities版本兼容。我在实际项目里遇到过物理包和核心包版本不匹配导致运行时报错的坑。

2.2 Entity、ComponentData、System的分工

先建立一个概念模型,把这仨分清楚:

概念本质类比
Entity一个int ID,没有数据没有行为一个记录编号,对应数据库里的一行
Component(IComponentData)纯数据结构,只存数据不存逻辑一行里的一列(或者说一组列)
System处理一类Component的逻辑单元负责读写某一组列的SQL语句/存储过程

Entity本身不包含任何数据,它只是一个索引。真正决定一个Entity“是什么”的,是挂在他身上的组件集合。同一个Entity可以同时有Position组件、Velocity组件、RenderMesh组件,那么它在系统里就同时是一个物理对象、一个移动物体和一个渲染对象。

在Entities 1.0里,自定义的组件类型统一继承IComponentData。这个接口什么方法都没有,它的作用只是给编译器一个标记,告诉ECS“这是个可以放进Chunk里管理的数据类型”。对比MonoBehaviour,IComponentData不能有方法,不能有引用类型字段,只能存值类型和Blob数据引用。这听起来很限制,但实际上这个限制是为了把数据紧凑地塞进连续内存里做铺垫。

System的写法也有变化。旧的ComponentSystem已经废弃,现在推荐使用ISystem接口配合IJobEntity。后面有专门章节细说。

2.3 World、Archetype、Chunk:ECS内存布局的底层逻辑

搞懂ECS的内存模型,你就掌握了这个系统的灵魂。Unity ECS里,所有Entity和组件都生活在World中。默认情况下,运行时会自动创建一个默认World,你在场景里烘焙出来的Entity都会进到这个World里。你也可以自己创建独立的World来分割逻辑,比如服务器端和客户端逻辑分离,但这个需求相对进阶,初学阶段用默认World就够了。

World内部是按Archetype(原型)来组织Entity的。什么是Archetype?它指的是一个Entity上组件类型的唯一组合。比如:

  • Archetype A:Position + Velocity
  • Archetype B:Position + Velocity + RenderMesh
  • Archetype C:Position + Health

相同Archetype的所有Entity会被分配到同一个Chunk里。Chunk本质上是一块固定大小的连续内存,默认大小是16KB,里面用SoA(Structure of Arrays,数组结构体)的形式存放每种组件的数组。

这种布局方式有一个巨大的好处:当你需要遍历所有含Position组件的Entity时,只需要在Chunk里连续读取Position数组。而且如果你要同时修改Position和Velocity,这两个数组在Chunk里也是连续存放的,访问命中率极高。相反,如果某个Entity是Archetype B,它孤零零地跟另外几百个不同Archetype的Entity混在一起,都属于正常情况——ECS按类型聚堆,不按对象聚堆。这就是它和传统GameObject的内存组织方式的根本不同点。

实操心得:调试期间可以通过Unity的Entities Profiler模块看到当前每个Archetype的Entity数量和Chunk数量。如果你发现某个Archetype只有两三个Entity还占用了两个十六字节的Chunk,最好反过来想想组件组合设计是不是太碎了——Archetype数量过多会让EntityQuery的匹配效率下降,这是一个需要适时做权衡的指标。

3. 从零实现一个完整ECS移动系统

说了这么多理论,直接上手写一套能跑的代码。我们做一个尽可能简单但又覆盖了完整开发流程的示例:让一千个Cube匀速向前飞。同时加上随机旋转,这样看起来更直观一些。

3.1 定义自己的IComponentData

新建一个脚本文件MoveComponent.cs,内容如下:

using Unity.Entities; public struct MoveSpeed : IComponentData { public float Speed; public float RotationSpeed; }

就这么简单。MoveSpeed是一个纯数据结构,字段全是值类型(float),没有方法、没有属性、没有引用类型成员。按照Unity官方建议,字段数量不要太多,尽量保证组件语义单一。这里我把线速度和角速度放在一起算是妥协,实际项目中更推荐把移动速度和旋转速度拆成两个独立组件,这样某个System只需要处理“只带移动速度的实体”时就不必被旋转相关的数据拖累缓存。

如果你需要在组件里存数组、字符串之类的变长数据,不能直接放进去。IComponentData要求所有字段在编译期就能确定大小。变长数据要用BlobAsset或者DynamicBuffer,那是另一个话题了,初学阶段先避开。

3.2 Authoring与Baker:把GameObject变成Entity

你当然可以在代码里直接通过EntityManager.CreateEntity手动创建Entity然后手动加组件,但场景美术资源基本都是在Editor里摆好、以普通GameObject存在的。所以DOTS提供了一套"烘焙"流程:你在编辑器里看到的仍是普通GameObject,构建(进入PlayMode)时通过Baker把这些GameObject转换成Entity形态,包括它身上的自定义Authoring组件和Transform数据。

先写一个MonoBehaviour类,负责在编辑器里暴露参数:

using UnityEngine; public class MoveSpeedAuthoring : MonoBehaviour { public float Speed = 1f; public float RotationSpeed = 10f; }

然后在同一个文件里(或另一个文件,官方推荐同文件)写Baker逻辑:

using Unity.Entities; using Unity.Mathematics; public class MoveSpeedBaker : Baker<MoveSpeedAuthoring> { public override void Bake(MoveSpeedAuthoring authoring) { var entity = GetEntity(TransformUsageFlags.Dynamic); AddComponent(entity, new MoveSpeed { Speed = authoring.Speed, RotationSpeed = authoring.RotationSpeed }); } }

几个关键点。

GetEntity(TransformUsageFlags.Dynamic)的作用是向烘焙系统声明这个实体需要动态变换数据。在Entities 1.0里,Transform已经不是一个默认组件,而是由Baker按需添加。如果你不传TransformUsageFlags.Dynamic,烘焙后的Entity上是没有Transform相关组件的,你的移动逻辑就不知道把东西挪到哪。选Dynamic是因为我们要在运行时修改它的位置和旋转;如果是静态物体,用TransformUsageFlags.WorldSpace就够了。

在场景里给Cube挂上MoveSpeedAuthoring组件,设置好参数。进入PlayMode时,这个Cube不再以GameObject形式存在(如果你开了“DOTS转换模式”),而是变成了场景中的一个Entity。它的位置信息,存在LocalTransform组件里。

3.3 用IJobEntity编写第一个System

重头戏来了。写System:

using Unity.Burst; using Unity.Entities; using Unity.Jobs; using Unity.Mathematics; using UnityEngine; public partial struct MoveSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { } [BurstCompile] public void OnUpdate(ref SystemState state) { float deltaTime = SystemAPI.Time.DeltaTime; new MoveJob { DeltaTime = deltaTime }.Schedule(); } [BurstCompile] public partial struct MoveJob : IJobEntity { public float DeltaTime; private void Execute(ref LocalTransform transform, in MoveSpeed speed) { transform.Position += new float3(0, 0, speed.Speed * DeltaTime); transform.Rotation = math.mul( transform.Rotation, quaternion.RotateY(speed.RotationSpeed * DeltaTime)); } } }

我故意把System和Job写在一起,这是比较紧凑的写法,便于阅读。你也可以把Job提到外部独立定义,效果一样。

这里有个特别重要的编码习惯:MoveJobref LocalTransform声明了可写访问,用in MoveSpeed声明了只读访问。这个声明不是装饰性的,它会直接影响Job调度器对依赖关系的判断。如果另一个System同时以只读方式访问MoveSpeed,它们可以并行;如果另一个System也声明了可写访问MoveSpeed,那么调度器就会让它们串行排队,避免数据竞争。这套由类型系统驱动的依赖关系体系,非常优雅。

SystemAPI.Time.DeltaTime取代了旧版Time.deltaTime,因为在Job里你没办法直接访问UnityEngine的主线程API。SystemAPI提供了一整套可以在Job和System中安全访问数据的方法,后续讲查询的时候还会遇到。

OnUpdate里我用了Schedule()来安排这个Job,这是最简单的方式。它的意思是“作为当前System的一个并发Job,尽早调度执行”。如果你希望等待这个Job完成后再做别的事,可以使用.Schedule()之后在下一个System的依赖关系里自动等待,框架会帮你按依赖链管理时序。System的默认 Update 顺序是按组件创建顺序来的,但你可以用[UpdateAfter][UpdateBefore]特性来显式控制流程。比如移动系统更新完毕之后,位置变化才被相机跟随系统读取,就可以在相机System上声明[UpdateAfter(typeof(MoveSystem))]

把这段代码保存,回到Unity。场景里放一千个带MoveSpeedAuthoring的Cube,挂上MoveSystem(ISystem会自动纳入系统调度,不需要手动挂载),进入播放模式看看效果。

4. EntityQuery查询原理与数据访问注意事项

移动示例跑起来后,我们来深入一个核心机制:System是怎么找到哪些Entity需要处理,以及查询过程中的性能和正确性陷阱。

4.1 EntityQuery是怎么工作的

回头看MoveJob : IJobEntity,函数签名是Execute(ref LocalTransform, in MoveSpeed)。框架在编译时会自动分析这个签名,生成一个对应的EntityQuery,匹配所有“同时具有LocalTransform和MoveSpeed组件”的Entity。符合这个Archetype的Entity会被筛选出来,然后一个Chunk接一个Chunk地喂给Execute函数执行。

但实际开发中你经常会遇到更复杂的查询,比如“只处理速度大于0的单位”或“忽略死亡标记为true的单位”。IJobEntity支持用特性来扩展匹配条件:

public partial struct MoveJob : IJobEntity { private void Execute( ref LocalTransform transform, in MoveSpeed speed, in DeadTag deadTag) { ... } }

只要把DeadTag也放进Execute参数列表,查询就会自动要求“同时包含DeadTag”。如果只想排除,把参数命名为EnabledRef之类的没什么用——查询语义是由[WithAll][WithNone]这类特性控制的。在ISystem里,你可以显式构造自定义查询:

var query = SystemAPI.QueryBuilder() .WithAll<LocalTransform, MoveSpeed>() .WithNone<DeadTag>() .Build();

或者,如果你需要更细粒度地筛选数据内容(比如speed.Speed > 0),标准做法是在System里先跑一遍查询结果,做一次轻量过滤,或者使用IJobEntity配合WithEntityQueryOptions选项。另一个更高阶的优化是把筛选字段放进IEnableableComponent(可启用组件)里,利用ComponentLookup<T>.SetComponentEnabled来控制是否参与System处理,这样就不需要遍历所有实体做条件判断了——这个优化在拥有成千上万个实体时收益非常明显。

4.2 结构变更:ECS性能杀手之一

这是所有ECS新手都会踩的大坑。

所谓结构变更(Structural Change),是指那些会改变Archetype划分、触发Chunk内存重排的操作。常见的包括:

  • EntityManager.CreateEntity
  • EntityManager.DestroyEntity
  • EntityManager.AddComponent<T>
  • EntityManager.RemoveComponent<T>
  • EntityManager.SetSharedComponent<T>(做了chunk重新分配)

问题在于,这些操作不是单纯的增删改,它们会触发ECS对整个Chunk数据做一次重新排列。比如你给一万个Entity加一个DeadTag,ECS需要把这些实体从原来的Chunk拷贝到新的、包含了DeadTag组件类型的Chunk里。这个过程中,所有正在访问这些Chunk的并行Job都必须停下来等待。在Unity的Profiler里,你会看到主线程出现一个长长的“结构变更等待”卡顿。

更要命的是,结构变更不能直接发生在Job里边。原因很直白:Job正在并行遍历Archetype的数据,这时你再往里增删实体,内存布局一乱,Job里持有的指针/索引就全失效了。所以运行时如果检测到这类操作,会直接抛异常给你看,这个等会儿在问题章节细讲。

那正确的做法是什么?

答案是EntityCommandBuffer(ECB)。它把结构变更操作记录下来,在合适的时机延迟执行。通常用法是在System里调用SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>()拿到ECB容器,然后在Job里记录命令,等到主线程安全时统一提交执行。

public partial struct KillJob : IJobEntity { public EntityCommandBuffer ECB; private void Execute(Entity entity, in Health health) { if (health.Value <= 0) { ECB.DestroyEntity(entity); } } } [BurstCompile] public void OnUpdate(ref SystemState state) { var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>(); var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged); new KillJob { ECB = ecb }.Schedule(); }

这里有一个很实用的经验:ECB有三种常见提交时机——EndSimulationBeginSimulationEndFixedStep。什么时候用你创建的ECB,取决于你在哪个阶段创建它的。如果你在某个自定义System的OnUpdate里创建,默认这个ECB会跟随该System执行完主线程部分后、在Syncpoint处提交。如果你希望批量的命令都在一帧末尾统一提交,就通过EndSimulation获取;如果你希望它们尽快生效,就通过BeginSimulation或EntityManager直接执行。注意:大量ECB命令也会占用内存,尽量批量提交,别每帧创建一堆零碎ECB。

4.3 Entity之间的引用:实体引用与相关注意事项

IComponentData里没有类对象引用字段,但ECS依然提供了在各Entity之间建立引用关系的机制,主要通过Entity类型的字段来实现。比如一个单位可以持有一个Entity Target;字段,指向它当前攻击的目标实体。

public struct AttackTarget : IComponentData { public Entity Target; }

这种引用本身非常轻量(本质上是个int),但它会在Chunk内占用连续空间的特性。访问某个Entity的Target字段是随机的内存跳转,谈不上糟糕但不够完美。另一个更高效的模式是把目标关系存储为“层级关系”(Parent/Child),利用LinkedEntityGroup来管理。比如武器的子实体挂在武器Entity下面,遍历时直接从Group里取,不需要再去Target查找。

一个容易踩坑的点是实体生命周期。你存了Entity Target,但Target那个实体在某一帧被销毁了,那你的引用就变成了悬空引用。如果你再用这个值去查找组件或者实体访问,轻则拿到无效数据,重则异常。要避免这个问题,你需要做两件事:

  • 使用EntityManager.Exists(entity)或者在查找前检查实体是否仍然有效。
  • 在销毁实体的System里统一扫描、清理所有指向该实体的引用(或者在引用方Query里排除DestroyTag)。

哪个方案更优取决于你的项目规模。一个实用性技巧是:把需要销毁的实体先打上DestroyTag,然后下一帧统一销毁。这样在销毁真正执行前,各个System还有机会访问到它并清理相关引用,避免了莫名奇妙就访问已销毁实体的问题。

5. 常见问题与排查技巧实录

这部分全是这些年实战攒下来的坑。有些是编译期就报错的,有些是运行期让你掉头发的,还有些是性能损耗隐蔽到肉眼根本发现不了的。

5.1 “结构变更在Job/System执行期间发生”报错

这个异常信息大致长这样:InvalidOperationException: The collection is in use and cannot be modified during iteration,或者带Structural changes are not allowed during iteration

高发场景是:在一个IJobEntity里直接调用EntityManager.AddComponentDestroyEntity,又或者在不同System间通过EntityManager动态修改正在查询数据的Chunk结构。

我见过最典型的错误是:

// 错误示例 public partial struct MyJob : IJobEntity { public EntityManager EntityManager; private void Execute(Entity entity) { EntityManager.AddComponent<DeadTag>(entity); } }

这样写100%会在运行时报错。解决办法就是第4.2节说的:改用ECB。把EntityManager从Job里拿掉,替换成EntityCommandBuffer,所有结构变更进入队列,在同步点统一处理。

还有一个比较隐蔽的场景:在System的OnUpdate主线程部分(不是Job里)调用EntityManager做结构变更,但这个System之前已经安排了其他Job还在跑。这种情况下Unity会主动强制做同步等待——性能不至于报错,但会白白卡住工作线程。正确做法是在查询数据之前就避免结构变更,或者把所有结构性操作推迟到System末尾统一调度ECB。

实操心得:如果你发现自己在一个System里既想遍历查询、又想删实体,把这个System拆成两个阶段来写——阶段一收集要删除的实体,阶段二通过ECB或者直接在System末尾集中删除。虽然看起来多了一步,但代码清晰度、运行效率、Bug率三个人都会给你正反馈。

5.2 Burst编译异常与调试技巧

Burst是DOTS性能的重要组成部分,但它对代码的约束也比较严格。一旦你启用了[BurstCompile],下面这些做法都会触发编译错误:

  • 调用调试方法如Debug.Log(Burst里没有可用的托管实现)
  • 使用List、Dictionary等托管容器
  • 使用委托(包括Lambda表达式在大片代码里)
  • 访问UnityEngine的大部分静态API

调Bug的时候,你可以临时把某个Job的[BurstCompile]注释掉,让它回退到普通C#运行。这样就能用Debug.Log打印临时信息来定位问题。Burst的报错会非常生硬,信息不够直接,所以这套“拆掉Burst再调试”的手法几乎人人都会用。

另一个常见问题是数据对齐。IComponentData里的字段布局要避免隐式填充,也就是对齐填充导致的性能损耗。简单说,一个组件里不要混用floatdouble,尽量把所有字段控制在4字节对齐或其倍数上。如果你的组件在内存里产生大量填充,Chunk能容纳的实体数量就会下降,缓存命中率也就跟着掉。你可以在官方文档里找到[StructLayout(LayoutKind.Sequential)]等控制布局的手段,但初学阶段建议保持组件字段类型统一。

5.3 Entity Inspector与System Profiler怎么看

调试Entity数据,最直观的工具是Entity Inspector,打开Window -> Entities -> Hierarchy。你会发现这个“场景窗口”里不是GameObject列表,而是Entity列表,点击任一Entity可以展开它的所有组件数据,像看数据库行记录一样逐字段检查。这个工具在你写Baker时特别有用——Food跑起来看不出来,但烘焙结果对不对一目了然。

性能排查用System Profiler。它也隐藏在Window -> Entities -> Profiler模块里。打开后你会看到一张时间轴,每个System占一行,深浅不一的色块表示每个System在帧里占用的耗时。拖拽放大后,可以精确看到哪几帧发生了结构变更风暴、哪几个System之间有大量同步等待。

最常看到的性能问题是“Entity查询导致的小Chunk碎片”。如果某个Archetype下只有极少实体,那么这个Archetype的Chunk也几乎装不满,在遍历时仍然要访问整块Chunk内存,白读了很多无关数据。调试时如果发现很多低密度Chunk,优先检查是不是Baker漏了某些组件或者System的查询条件过窄。

5.4 从MonoBehaviour迁移到ECS时的注意事项

最后分享一点迁移经验。很多人一上来就想把所有逻辑全换成ECS,甚至在同一个系统里既用GameObject又用Entity去同步数据。千万别这么干,你会被同步逻辑和生命周期问题直接淹没。我给一个稳妥的迁移路径:

先挑项目中某个数据量大、逻辑独立的系统(比如单位运动、子弹飞行、怪物群体AI)改成ECS,外界只通过Bridge(一座桥)连接。桥的作用是:在ECS侧,定期把特定计算结果写到一个普通的C#数组中;在MonoBehaviour侧,通过EntityManager同步给需要渲染或显示的对象。等ECS侧基本稳定后,再把桥两边的数据交互逐步简化。

我踩过的最大教训是“Entity和GameObject混用时生命周期管理极其痛苦”。比如一个单位在ECS里已经被销毁了,但它的GameObject还没被销毁,于是你需要在GameObject层遍历时频繁查询“这个Entity还存在吗”。这个问题不是无解,但开发成本会成倍增加。我的建议是:要么全用ECS(包括UI之外的业务逻辑),要么就把ECS限定在性能敏感的子系统中并做好边界,不要半吊子混搭。

6. 从示例到生产:几个进阶设计思路

当移动示例跑通、你开始考虑把ECS放进真实项目时,有几个容易被忽略的设计决策,我觉得值得在这里列出来。

6.1 组件拆分粒度怎么定

前面提到MoveSpeed里既有线速度又有角速度,实际生产需求通常会更大。组件拆得粗,意味着查询匹配范围过大,很多实体被白白遍历;拆得细,意味着Entity数量多、Archetype组合爆炸,查询匹配和Chunk分配都会受到负面影响。

我个人的经验法则是:把“变频率相同的数据”放一起,把“被同一个System使用、访问频率相仿的数据”放一起。比如位置和速度永远是移动System一起改,就放一起;而属性和状态是战斗系统用的,就别和移动数据混在一起。这样每个System查询到的数据都是它最需要的,缓存命中率最高。

6.2 Job调度器与并行粒度

IJobEntity对每个Chunk默认是并行处理的,但这不是只有一种方式。你可以用[BatchSize]特性调整每个Job处理多少个Entity:

[BatchSize(64)] public partial struct MoveJob : IJobEntity { ... }

BatchSize太小,任务分配的开销会超过实际计算收益;太大,有的工作线程就会闲置。一般经验值在32到128之间,具体数据与项目相关,建议用System Profiler实际测完后微调。

另外,两个System如果都只读访问同一类组件,在[BurstCompile]+ScheduleParallel的情况下,调度器会让它们并行跑。但如果一个写一个读,就会强制等待。如果业务上允许,优先把“读多写少”的数据放在同一个System的开头一次性读取,或者放到字段里作为只读参数传给后续Job,能有效减少跨System参考。

6.3 BlobAsset:复杂数据的正确打开方式

如果你需要给每个Entity配置不同的“基础属性”但不想让数据散落在每个组件里,BlobAsset是好选择。它允许把数组、字符串等数据作为不可变资源存在World内存里,所有Entity共享同一块只读数据,然后组件里只存一个引用指针。

比如怪物有不同的技能列表,技能列表很长且只读。把技能数据做成BlobAsset,组件里存BlobAssetReference<MonsterStats>,每个Entity可以指向同一个共用数据。这样内存占用低且不会拖慢缓存。

BlobAsset的构建需要特定的BlobBuilder流程,而且不能动态修改,更适合做静态配置类数据。如果你尝试往BlobAsset里写东西,崩溃在等着你——因为它的内存是以只读方式映射的。

6.4 使用SystemAPI访问单例数据

除了遍历查询,System里经常需要读取“全局唯一”的数据,比如玩家输入状态、全局配置、某种共享资源。1.0里常用做法是先把这些数据做成一个组件(比如InputSingleton : IComponentData,里面存一个Bool标志向量),然后挂到一个固定Entity上,System里用SystemAPI.GetSingleton<InputSingleton>()来直接访问。

如果这个数据需要在多个System间共享,可以使用SystemAPI.GetSingletonRW<T>()获得可写引用,然后把它传给Job。注意,如果多个System同时声明要写同一个单例,调度器会让它们串行执行,这也是一个合理的同步点,比你自己做裸锁高效得多。

注意:单例组件和普通组件一样,也是放在World里的数据,只是你主动约定它只存在一个实例。不要随意创建多个同名组件实体,否则GetSingleton会在运行时报“存在多个匹配实体”的错误。

7. 写在最后的一点个人体会

我从ECS 0.x一路用到1.0,最大的感受是:DOTS不是用来写小玩意的,它是为特定的性能问题而生的。如果项目只是几个UI界面、几个普通对象,老老实实用MonoBehaviour完全够用;但如果你面对的是上千上万个需要独立计算的对象,DOTS带来的架构清晰性和性能红利才能体现出来。它是一个需要时间学习的架构体系,但一旦用熟了,你在处理大规模运算时的思路会被彻底重塑。

最后一个实用小技巧:在系统里调试时,可以临时用UnityEngine.Debug.Log打印数据,但记得在发布前把所有Burst回调里的Log都清掉。这些Log不仅性能差,而且在开启Burst的Job中是直接编译不过的。我用过一种折中方案:定义一个全局宏ENABLE_DOTS_DEBUG,把调试Log放在System主线程部分(非Job内),这样既能保留调试通道,又不会拖累Job的Burst编译。

DOTS系列前几篇讲了Job System和Burst,这篇把Entities串了起来。如果能把这三者结合运用,再复杂的同屏场景也能做到流畅运行。下一篇我打算聊聊Unity的混合渲染流程,也就是ECS管理逻辑与GameObject渲染怎么共存,到时候见。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 5:02:12

创业公司训练和微调大模型,推荐选择哪些云平台?

创业公司训练和微调大模型&#xff0c;推荐选择哪些云平台&#xff1f;先分清预训练、微调和模型定制三条路线创业公司训练和微调大模型&#xff0c;选云平台之前最好先回答一个问题&#xff1a;公司是真的要从头训练模型&#xff0c;还是希望基于现有基础模型做微调和行业化定…

作者头像 李华
网站建设 2026/9/14 5:00:30

从ChatGPT到多智能体系统:大模型的技术突破与应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 5:00:26

圆偏振光膜和AR膜区别:基于作用光路与光学原理的对比分析

一、问题场景与阅读价值护眼钢化膜市场上频繁出现两个技术名词&#xff1a;圆偏振光膜和AR膜。很多用户以为它们是同一种技术的不同叫法&#xff0c;或者认为买了一种就不需要另一种。实际上&#xff0c;两者作用于完全不同的光路&#xff0c;解决的是不同维度的视觉问题。选错…

作者头像 李华
网站建设 2026/9/14 4:59:23

Python容器深度对比:元组、集合、字典的底层逻辑与选型指南

我干脆先把话说在前面&#xff1a;很多Python教程喜欢把元组、集合、字典拆成三章慢慢讲&#xff0c;乍一看很系统&#xff0c;但学完照样懵。真正折磨人的从来不是"元组怎么定义""字典怎么取值"这类API问题&#xff0c;而是——元组明明写着不可变&#x…

作者头像 李华
网站建设 2026/9/14 4:56:51

HC-SR501+ESP32零基础人体感应实战:MicroPython入门第一课

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 4:54:13

JoyShare三年复盘:从0到1000人的内容社群运营实践

“Joy&Share”这个名字&#xff0c;我用了整整三年。从最初在咖啡厅里和一个朋友的一次闲聊&#xff0c;到后来变成一个有一千多人参与、线上线下联动的内容分享社群&#xff0c;它教会我的事&#xff0c;远比任何一次职业晋升都多。如果你正打算做一个自己的内容品牌、社群…

作者头像 李华