ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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组件都要经过一次托管层的封装调用。比如通过GetComponentT()拿到的是一个包装过的对象这个操作本身就有额外开销。当你在循环里重复调用时这部分开销会被放大得非常明显。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.CreateEntity、ComponentSystem、[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没有数据没有行为一个记录编号对应数据库里的一行ComponentIComponentData纯数据结构只存数据不存逻辑一行里的一列或者说一组列System处理一类Component的逻辑单元负责读写某一组列的SQL语句/存储过程Entity本身不包含任何数据它只是一个索引。真正决定一个Entity“是什么”的是挂在他身上的组件集合。同一个Entity可以同时有Position组件、Velocity组件、RenderMesh组件那么它在系统里就同时是一个物理对象、一个移动物体和一个渲染对象。在Entities 1.0里自定义的组件类型统一继承IComponentData。这个接口什么方法都没有它的作用只是给编译器一个标记告诉ECS“这是个可以放进Chunk里管理的数据类型”。对比MonoBehaviourIComponentData不能有方法不能有引用类型字段只能存值类型和Blob数据引用。这听起来很限制但实际上这个限制是为了把数据紧凑地塞进连续内存里做铺垫。System的写法也有变化。旧的ComponentSystem已经废弃现在推荐使用ISystem接口配合IJobEntity。后面有专门章节细说。2.3 World、Archetype、ChunkECS内存布局的底层逻辑搞懂ECS的内存模型你就掌握了这个系统的灵魂。Unity ECS里所有Entity和组件都生活在World中。默认情况下运行时会自动创建一个默认World你在场景里烘焙出来的Entity都会进到这个World里。你也可以自己创建独立的World来分割逻辑比如服务器端和客户端逻辑分离但这个需求相对进阶初学阶段用默认World就够了。World内部是按Archetype原型来组织Entity的。什么是Archetype它指的是一个Entity上组件类型的唯一组合。比如Archetype APosition VelocityArchetype BPosition Velocity RenderMeshArchetype CPosition Health相同Archetype的所有Entity会被分配到同一个Chunk里。Chunk本质上是一块固定大小的连续内存默认大小是16KB里面用SoAStructure 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 : BakerMoveSpeedAuthoring { 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重头戏来了。写Systemusing 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提到外部独立定义效果一样。这里有个特别重要的编码习惯MoveJob用ref 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挂上MoveSystemISystem会自动纳入系统调度不需要手动挂载进入播放模式看看效果。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() .WithAllLocalTransform, MoveSpeed() .WithNoneDeadTag() .Build();或者如果你需要更细粒度地筛选数据内容比如speed.Speed 0标准做法是在System里先跑一遍查询结果做一次轻量过滤或者使用IJobEntity配合WithEntityQueryOptions选项。另一个更高阶的优化是把筛选字段放进IEnableableComponent可启用组件里利用ComponentLookupT.SetComponentEnabled来控制是否参与System处理这样就不需要遍历所有实体做条件判断了——这个优化在拥有成千上万个实体时收益非常明显。4.2 结构变更ECS性能杀手之一这是所有ECS新手都会踩的大坑。所谓结构变更Structural Change是指那些会改变Archetype划分、触发Chunk内存重排的操作。常见的包括EntityManager.CreateEntityEntityManager.DestroyEntityEntityManager.AddComponentTEntityManager.RemoveComponentTEntityManager.SetSharedComponentT做了chunk重新分配问题在于这些操作不是单纯的增删改它们会触发ECS对整个Chunk数据做一次重新排列。比如你给一万个Entity加一个DeadTagECS需要把这些实体从原来的Chunk拷贝到新的、包含了DeadTag组件类型的Chunk里。这个过程中所有正在访问这些Chunk的并行Job都必须停下来等待。在Unity的Profiler里你会看到主线程出现一个长长的“结构变更等待”卡顿。更要命的是结构变更不能直接发生在Job里边。原因很直白Job正在并行遍历Archetype的数据这时你再往里增删实体内存布局一乱Job里持有的指针/索引就全失效了。所以运行时如果检测到这类操作会直接抛异常给你看这个等会儿在问题章节细讲。那正确的做法是什么答案是EntityCommandBufferECB。它把结构变更操作记录下来在合适的时机延迟执行。通常用法是在System里调用SystemAPI.GetSingletonEndSimulationEntityCommandBufferSystem.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.GetSingletonEndSimulationEntityCommandBufferSystem.Singleton(); var ecb ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged); new KillJob { ECB ecb }.Schedule(); }这里有一个很实用的经验ECB有三种常见提交时机——EndSimulation、BeginSimulation、EndFixedStep。什么时候用你创建的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.AddComponent、DestroyEntity又或者在不同System间通过EntityManager动态修改正在查询数据的Chunk结构。我见过最典型的错误是// 错误示例 public partial struct MyJob : IJobEntity { public EntityManager EntityManager; private void Execute(Entity entity) { EntityManager.AddComponentDeadTag(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.LogBurst里没有可用的托管实现使用List、Dictionary等托管容器使用委托包括Lambda表达式在大片代码里访问UnityEngine的大部分静态API调Bug的时候你可以临时把某个Job的[BurstCompile]注释掉让它回退到普通C#运行。这样就能用Debug.Log打印临时信息来定位问题。Burst的报错会非常生硬信息不够直接所以这套“拆掉Burst再调试”的手法几乎人人都会用。另一个常见问题是数据对齐。IComponentData里的字段布局要避免隐式填充也就是对齐填充导致的性能损耗。简单说一个组件里不要混用float和double尽量把所有字段控制在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组件里存BlobAssetReferenceMonsterStats每个Entity可以指向同一个共用数据。这样内存占用低且不会拖慢缓存。BlobAsset的构建需要特定的BlobBuilder流程而且不能动态修改更适合做静态配置类数据。如果你尝试往BlobAsset里写东西崩溃在等着你——因为它的内存是以只读方式映射的。6.4 使用SystemAPI访问单例数据除了遍历查询System里经常需要读取“全局唯一”的数据比如玩家输入状态、全局配置、某种共享资源。1.0里常用做法是先把这些数据做成一个组件比如InputSingleton : IComponentData里面存一个Bool标志向量然后挂到一个固定Entity上System里用SystemAPI.GetSingletonInputSingleton()来直接访问。如果这个数据需要在多个System间共享可以使用SystemAPI.GetSingletonRWT()获得可写引用然后把它传给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渲染怎么共存到时候见。
返回列表