Unity ECS与数据驱动设计:从性能瓶颈到高并发游戏架构实战

📅 2026/8/7 23:11:37
Unity ECS与数据驱动设计:从性能瓶颈到高并发游戏架构实战
1. 项目概述从一次“守望先锋”式的性能灾难说起几年前我接手了一个看似普通的Unity手游项目。初期一切顺利直到我们开始制作一个大型的多人战场场景——屏幕上同时出现了上百个士兵单位每个单位都有自己的AI逻辑、寻路、状态机和特效。很快噩梦开始了帧率FPS从稳定的60帧骤降到20帧以下手机发烫玩家操作延迟高得离谱。我们尝试了所有教科书式的优化对象池、贴图合并、LOD但收效甚微。问题的核心在于我们当时使用的是Unity传统的面向对象OOP和基于MonoBehaviour的组件Component架构。当上千个GameObject每个都挂着数个脚本每帧都在进行GetComponent、遍历列表、更新状态时CPU缓存命中率极低造成了巨大的性能瓶颈。这让我想起了暴雪的《守望先锋》。这款游戏以其在复杂场景下依然流畅的60帧体验而闻名尤其是在早期硬件上实现12v12的激烈对战。后来暴雪的技术分享揭晓了秘密之一他们大规模采用了数据驱动设计和类似ECSEntity Component System的架构。这不是一个简单的“优化技巧”而是一次根本性的范式转变。今天我就结合那次踩坑的经历和《守望先锋》的启示来深入聊聊Unity中的数据驱动设计与ECS这不仅仅是一个“避坑指南”更是一套应对高复杂度、高性能需求项目的设计哲学和工程实践。无论你是正在为性能发愁的开发者还是对现代游戏架构感到好奇的学习者这篇文章都将为你提供一个从理论到实操的完整视角。2. 核心理念拆解为什么是数据驱动与ECS在深入代码之前我们必须先理解背后的“为什么”。传统的Unity开发模式我们很熟悉创建一个GameObject然后挂载各种MonoBehaviour脚本如PlayerController、Health、Renderer。这些脚本既是数据字段的容器也是行为方法的执行者。这种模式直观易于上手但在规模膨胀时其弊端会暴露无遗。2.1 传统面向对象模式的瓶颈缓存不友好Cache UnfriendlyCPU从内存读取数据时并不是一个字节一个字节地读而是以“缓存行”Cache Line通常64字节为单位。在OOP中一个Player对象可能包含位置、血量、速度等数据和一堆方法。当我们遍历一个ListPlayer来更新所有玩家的位置时CPU加载的第一个缓存行里除了我们需要的position数据还可能包含playerName、score等本次计算用不到的字段甚至包含虚函数表指针。这造成了缓存空间的浪费。更糟糕的是这些对象在内存中通常是分散非连续存储的遍历时CPU需要频繁地从不同内存地址加载数据导致缓存命中率低下大量时间花在了等待数据从内存加载到缓存上这就是所谓的“缓存抖动”。紧耦合与数据组织混乱逻辑系统和数据状态捆绑在同一个类里。一个EnemyAI脚本可能既管理寻路路径数据又执行寻路计算逻辑还直接修改Health组件的数据。这导致系统边界模糊代码难以测试、复用和并行化。性能开销大每帧Unity需要为每个激活的MonoBehaviour调用Update()、FixedUpdate()等生命周期方法即使这个脚本在本帧什么也不做也存在调用开销。大量的GetComponent调用也是性能杀手。2.2 数据驱动设计与ECS的救赎数据驱动设计的核心思想是将数据状态与逻辑行为分离并以数据为中心来组织程序。ECS是这一思想在游戏开发领域一个非常具体和高效的架构模式。Entity实体一个轻量级的ID或句柄它本身没有任何数据或逻辑。它仅仅是一个标识符用于关联一组Components。在Unity的DOTS面向数据的技术栈实现中Entity就是一个简单的结构体。Component组件纯粹的数据容器。它只包含状态数据没有任何方法逻辑。例如PositionComponent只包含float3 positionHealthComponent只包含float currentHealth, float maxHealth。System系统纯粹的逻辑执行者。它负责处理具有特定组件组合的实体。例如MovementSystem会遍历所有拥有PositionComponent和VelocityComponent的实体并根据速度更新它们的位置。ECS如何解决上述问题极致的缓存友好性这是ECS最大的优势。所有同类型的Component数据在内存中是连续存储Archetype内存块或Chunk中的。MovementSystem要更新位置时它直接在一个连续的内存块上顺序处理所有的PositionComponent和VelocityComponent。CPU可以高效地预加载数据到缓存几乎消除了缓存未命中使得批量处理数据的效率极高。这就像从散落一地的文件柜里找文件OOP变成了从一排整齐、按顺序编号的文件架上直接抽取ECS。清晰的关注点分离数据是数据逻辑是逻辑。HealthComponent只负责存血量DamageSystem只负责计算伤害并修改HealthComponent的数据。这使得每个System都变得小巧、专注、易于测试和并行化。高效的并行化由于数据是连续且独立的System之间如果没有数据依赖可以很容易地放到多个线程Job中并行执行。Unity的Job System和Burst Compiler与ECS是天作之合能将计算密集型任务如物理、动画、AI决策的性能榨干。《守望先锋》团队正是通过将游戏中的核心逻辑如命中判定、状态更新、运动模拟重构为数据驱动的系统才实现了在复杂场景下依然稳定的高性能。他们可能没有使用和Unity ECS完全一样的API但思想是相通的。注意ECS不是银弹。它对于大量同类实体如子弹、粒子、NPC的模拟优化效果惊人但对于游戏UI、游戏管理器、一次性脚本等传统的MonoBehaviour可能更简单直接。一个成功的项目往往是混合架构。3. Unity DOTS 实战从零构建一个ECS小 demo理论说再多不如动手一试。我们用一个超简单的例子来感受ECS的流程在场景中生成一堆Cube并让它们匀速旋转。我们将使用Unity最新的DOTS包Entities, Physics, Hybrid Renderer等。3.1 环境准备与包安装首先你需要一个较新版本的Unity推荐2022.3 LTS或更新版本。打开Package ManagerWindow Package Manager。确保“Packages”下拉菜单选择的是“Unity Registry”。搜索并安装以下核心包EntitiesECS的核心框架。Entities Graphics(旧称Hybrid Renderer)用于渲染ECS实体的包。Unity PhysicsDOTS物理系统。Burst高性能编译器能将C# Job代码编译成高度优化的本地代码。Collections提供了对托管Managed和非托管Unmanaged集合的低开销支持。安装后你可能会看到一些警告或需要重启Unity按提示操作即可。3.2 定义组件Component—— 纯数据组件必须是结构体struct并实现IComponentData接口。我们创建一个旋转组件。using Unity.Entities; // 这是一个纯数据组件只包含旋转速度 public struct RotationSpeed : IComponentData { public float RadiansPerSecond; // 使用弧度制便于计算 }3.3 定义系统System—— 纯逻辑系统继承自SystemBase或更底层的ComponentSystemBase。我们创建一个在每帧更新实体旋转的系统。using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; using UnityEngine; // 部分类允许我们将系统代码分散在多个文件但通常不需要。 public partial class RotationSystem : SystemBase { protected override void OnUpdate() { // 获取当前帧的时间增量。在System中使用SystemAPI.Time.DeltaTime。 float deltaTime SystemAPI.Time.DeltaTime; // 方式一使用Entities.ForEach (较旧但直观的API部分场景仍适用) // Entities.ForEach((ref LocalTransform transform, in RotationSpeed speed) // { // transform transform.RotateY(speed.RadiansPerSecond * deltaTime); // }).ScheduleParallel(); // ScheduleParallel() 是关键它尝试并行执行。 // 方式二推荐使用 SystemAPI.Query .ForEach (更新的IJobEntity方式) // 它更灵活且与Burst编译、手动Job调度结合得更好。 foreach (var (transform, speed) in SystemAPI.QueryRefRWLocalTransform, RefRORotationSpeed()) { // RefRWT 表示可读写的引用RefROT 表示只读引用。 transform.ValueRW transform.ValueRO.RotateY(speed.ValueRO.RadiansPerSecond * deltaTime); } // 注意这个简单的foreach在主线程运行。对于大量实体应使用ScheduleParallel。 // 更优化的写法是使用IJobEntity或IJobChunk这里为简化先使用主线程遍历。 } }关键点解析SystemAPI.Query用于查询拥有特定组件组合的实体。这里查询同时拥有LocalTransform和RotationSpeed的实体。RefRWT/RefROTECS 1.0Entities 1.0引入的安全访问包装器用于在System中安全地读写组件数据能帮助检测一些数据竞争问题。LocalTransformDOTS中新的变换组件替代了旧的Translation,Rotation,Scale组件。注释掉的Entities.ForEach().ScheduleParallel()展示了如何将工作并行化。对于旋转这种简单的计算即使主线程遍历也很快但养成使用并行查询的习惯很重要。3.4 创建实体Entity并装配组件我们不能直接用GameObject.Instantiate来创建ECS实体。有几种方式这里演示通过MonoBehaviour一个Authoring组件在编辑器里创建并转换。创建Authoring脚本这是一个传统的MonoBehaviour用于在编辑器里配置数据。using Unity.Entities; using UnityEngine; // 添加ConvertToEntity组件它会在游戏运行时将本GameObject转换为Entity。 [RequireComponent(typeof(ConvertToEntity))] public class RotatingCubeAuthoring : MonoBehaviour { public float DegreesPerSecond 180f; // 在Inspector中配置用角度更直观 // 这个类用于将MonoBehaviour数据“烘焙”到ECS组件中。 // 它继承自BakerTT是Authoring脚本的类型。 class Baker : BakerRotatingCubeAuthoring { public override void Bake(RotatingCubeAuthoring authoring) { var entity GetEntity(TransformUsageFlags.Dynamic); // 添加RotationSpeed组件到实体并将角度转换为弧度。 AddComponent(entity, new RotationSpeed { RadiansPerSecond math.radians(authoring.DegreesPerSecond) }); // LocalTransform组件通常由ConvertToEntity系统自动从GameObject的Transform转换而来。 // 如果不需要特殊处理我们不需要手动添加。 } } }在场景中创建在场景中创建一个CubeGameObject。移除它自带的MeshRenderer和MeshFilter因为DOTS渲染需要不同的方式。为它添加ConvertToEntity组件。为它添加RotatingCubeAuthoring组件并设置DegreesPerSecond比如180。为它添加MeshAuthoring组件来自Entities Graphics包或RenderMesh等组件来定义其渲染表现。一个更简单的方法是使用预制的DefaultMesh实体。但为了快速看到效果我们可以用一个混合Hybrid渲染的捷径。使用混合渲染器快速预览确保安装了Entities Graphics包。在Cube对象上添加RenderMesh组件已过时或更好的添加MeshInstanceRenderer组件具体名称可能随版本变化最新版可能是MaterialMeshInfo等。更通用的方法是在Cube上添加MeshFilter和MeshRenderer我们刚才移除了现在加回来。在ConvertToEntity组件上将Conversion Mode设置为Convert And Inject Game Object或类似选项新版本可能是Convert And Destroy或Convert And Inject。这种模式下GameObject的渲染部分仍由传统的GameObject渲染管线处理而逻辑部分如Transform更新则由ECS系统驱动。这虽然不纯粹但非常适合原型开发和调试。3.5 运行与观察进入Play模式。你应该能看到Cube在旋转。在Unity编辑器的Window Analysis Entity Debugger中你可以打开实体调试器查看当前世界的所有实体、原型Archetype和组件。找到你的Cube实体可以看到它关联了LocalTransform和RotationSpeed组件。RotationSystem正在每帧更新它的LocalTransform。实操心得初次接触DOTS从“混合”模式入手心理负担最小。先让逻辑跑在ECS上渲染仍用传统方式逐步迁移。Entity Debugger是你调试ECS的利器一定要习惯使用它来观察实体和组件的状态。如果Cube没旋转检查1.RotationSystem是否被创建并启用默认自动创建2. 实体是否成功转换查看Entity Debugger3. 组件数据是否正确添加。4. 深入核心ECS架构下的性能优化与设计模式当我们掌握了基础就要思考如何用ECS构建更复杂、更高效的系统。这涉及到架构设计。4.1 原型Archetype与内存块Chunk这是理解ECS性能的关键。在ECS中实体不是随意存储的。原型Archetype定义了一组实体所拥有的唯一组件类型组合。例如所有拥有LocalTransform和RotationSpeed的实体属于一个原型如果再添加一个Health组件就属于另一个不同的原型。所有属于同一原型的实体的组件数据被存储在称为内存块Chunk的连续内存单元中。每个Chunk大小固定比如16KB可以容纳多个实体的数据。当一个实体的组件组合发生变化如添加或移除组件它会被移动到另一个匹配其新原型的Chunk中。设计启示保持原型稳定频繁地添加/移除组件会导致实体在Chunk间移动有开销。设计时尽量让实体的组件组合在生命周期内稳定。高效查询的基础SystemAPI.Query之所以快是因为它直接遍历特定原型的Chunk而不是所有实体。4.2 使用IJobEntity进行并行化我们之前的RotationSystem在主线程循环。要处理成千上万的实体必须并行化。我们可以将工作定义为一个Job。using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; // 定义一个Burst编译的Job [BurstCompile] public partial struct RotationJob : IJobEntity { public float DeltaTime; // 通过[Unity.Entities.ChunkIndexInQuery]可以获取一些上下文信息非必需。 void Execute([Unity.Entities.ChunkIndexInQuery] int chunkIndex, ref LocalTransform transform, in RotationSpeed speed) { transform transform.RotateY(speed.RadiansPerSecond * DeltaTime); } } // 更新System用于调度这个Job public partial class RotationJobSystem : SystemBase { protected override void OnUpdate() { var job new RotationJob { DeltaTime SystemAPI.Time.DeltaTime }; // 默认情况下ScheduleParallel会尝试根据核心数将工作分割并行。 // 它返回一个JobHandle如果需要可以依赖或合并这个Handle。 job.ScheduleParallel(); // 注意这里我们没有显式地获取jobHandle并调用Complete()。 // 因为SystemBase会在本帧晚些时候自动管理这些依赖。 } }关键点[BurstCompile]让Burst编译器优化此Job性能提升可能达到数倍甚至数十倍。IJobEntity一个方便的接口用于定义对实体进行操作的Job。编译器会根据Execute方法的签名自动生成查询。ScheduleParallel()将Job调度到多个工作线程上并行执行。这是发挥多核CPU威力的关键。数据安全Job系统会自动检测数据依赖。如果两个Job都要写同一个组件系统会防止它们并行避免竞争条件。4.3 组件数据访问与修改除了在System中直接读写有时我们需要从其他地方如另一个System、MonoBehaviour访问或修改ECS数据。这需要用到EntityManager或SystemAPI。// 在某个System中想根据条件动态添加组件 protected override void OnUpdate() { var ecb new EntityCommandBuffer(WorldUpdateAllocator); // 使用Allocator.TempJob可能更优但需要手动Dispose foreach (var (transform, health, entity) in SystemAPI.QueryRefROLocalTransform, RefROHealthComponent().WithEntityAccess()) { if (health.ValueRO.CurrentHealth 0) { // 添加一个“死亡标记”组件 ecb.AddComponentDeadTag(entity); // 或者移除渲染组件 ecb.RemoveComponentMaterialMeshInfo(entity); } } // EntityCommandBuffer必须在主线程Playback或者通过Schedule来在Job中记录命令。 ecb.Playback(EntityManager); ecb.Dispose(); // 重要必须手动释放。 }重要提示在Job中不能直接调用EntityManager的方法因为它不是线程安全的。必须在Job中通过EntityCommandBufferECB来记录“添加组件”、“销毁实体”等结构性更改命令然后在主线程或另一个安全的点执行这些命令。EntityCommandBuffer是ECS中管理结构性变化的推荐模式。4.4 系统System的组织与更新顺序多个System之间可能有依赖关系。例如MovementSystem需要在InputSystem之后运行RenderingSystem需要在所有逻辑系统之后运行。Unity ECS通过[UpdateInGroup]和[UpdateBefore]/[UpdateAfter]属性来控制。// 定义一个自定义的SystemGroup [UpdateInGroup(typeof(SimulationSystemGroup))] // 放在模拟组中 public partial class MyCustomSimulationGroup : ComponentSystemGroup { } // 将系统放入自定义组并指定顺序 [UpdateInGroup(typeof(MyCustomSimulationGroup))] [UpdateBefore(typeof(EndSimulationEntityCommandBufferSystem))] public partial class RotationSystem : SystemBase { ... } [UpdateInGroup(typeof(MyCustomSimulationGroup))] [UpdateAfter(typeof(RotationSystem))] // 明确在RotationSystem之后运行 public partial class CollisionDetectionSystem : SystemBase { ... }默认的SystemGroup包括InitializationSystemGroup、SimulationSystemGroup、PresentationSystemGroup等它们按顺序执行。合理组织System更新顺序是保证游戏逻辑正确性的基础。5. 实战避坑与高级技巧纸上得来终觉浅绝知此事要躬行。下面分享一些我在实际项目中踩过的坑和总结的技巧。5.1 陷阱一滥用EntityCommandBufferEntityCommandBufferECB是管理结构性变化的利器但使用不当会导致性能问题或bug。陷阱在每帧的OnUpdate中创建新的ECB但忘记调用Playback和Dispose。这会导致内存泄漏因为ECB分配了内存。正确做法protected override void OnUpdate() { // 推荐使用单例系统提供的ECB如BeginSimulationEntityCommandBufferSystem var ecbSingleton SystemAPI.GetSingletonBeginSimulationEntityCommandBufferSystem.Singleton(); var ecb ecbSingleton.CreateCommandBuffer(World.Unmanaged); // ... 使用ecb记录命令 ... // 不需要手动Playback和Dispose单例系统会处理。 }或者在Job中使用EntityCommandBuffer.ParallelWriter并确保ECB被正确调度和完成。心得对于简单的、单线程的结构性更改也可以考虑在System的OnUpdate中直接使用EntityManager主线程安全但代码结构会不如ECB清晰。ECB的优势在于可以将命令记录推迟执行便于在Job中操作。5.2 陷阱二忽视Burst兼容性Burst编译器虽然强大但有其限制。在[BurstCompile]的Job或函数中只能调用它支持的内置函数和数学库Unity.Mathematics不能调用任何托管Managed代码如UnityEngine.Random要用Unity.Mathematics.Random、Debug.Log、访问GameObject等。陷阱在Burst Job中不小心调用了Debug.Log导致编译错误或运行时异常。排查查看Console窗口中的Burst编译错误信息。确保Job中所有代码都是“非托管”的。技巧复杂计算或需要随机数时优先使用Unity.Mathematics中的函数。随机数种子需要从System传入Job。5.3 陷阱三并行Job中的数据竞争即使使用了Job如果多个并行Job写入同一块内存也会发生数据竞争。ECS通过安全系统如RefRW和Job依赖来自动防止大部分情况但开发者仍需小心。场景一个DamageSystemJobA遍历所有实体计算伤害另一个HealSystemJobB遍历所有实体治疗。如果它们都写入同一个HealthComponent且被调度为并行执行就会出问题。ECS的解决默认情况下如果两个Job都声明为写入(RefRW)同一个组件类型Job调度器通常不会让它们真正并行或者会抛出安全错误。但更复杂的情况需要手动管理依赖。最佳实践使用Dependency属性。SystemBase的Dependency属性会自动管理本System创建的Job与之前System Job之间的依赖关系。在自定义Job调度时要正确处理返回的JobHandle。protected override void OnUpdate() { var jobHandle1 new JobA().ScheduleParallel(this.Dependency); // JobB 依赖于 JobA 的完成 var jobHandle2 new JobB().ScheduleParallel(jobHandle1); // 更新系统依赖告诉ECS“本系统现在依赖于jobHandle2的完成” this.Dependency jobHandle2; }5.4 技巧使用“标记组件”Tag Component和“共享组件”Shared Component标记组件一个不包含任何数据的组件IComponentData仅用于标记实体类型或状态。例如DeadTag、PlayerTag、NeedsInitializationTag。System可以通过查询是否拥有某个Tag组件来过滤实体非常高效。public struct EnemyTag : IComponentData { } // 空结构体即可 // 在System中查询 var query SystemAPI.QueryBuilder().WithAllLocalTransform, EnemyTag().Build();共享组件实现了ISharedComponentData的组件。所有拥有相同共享组件值的实体会被分组到同一个Chunk中。这适用于需要按特定值如渲染材质RenderMesh、图层批量处理的情况。但要注意修改实体的共享组件值会导致它移动到另一个Chunk开销较大不宜频繁修改。5.5 技巧与MonoBehaviour世界的交互Hybrid方案完全用ECS重写一个大型项目是不现实的。混合架构是必经之路。如何让ECS系统和传统的GameObject/MonoBehaviour通信ECS - MonoBehaviour在MonoBehaviour中可以通过World.DefaultGameObjectInjectionWorld.EntityManager获取EntityManager然后查询或修改ECS数据。但更优雅的方式是使用组件系统来驱动MonoBehaviour。例如创建一个SyncTransformToGameObject组件和一个对应的System该系统遍历拥有该组件的实体将其LocalTransform数据同步到关联的GameObject的Transform上。public struct LinkedGameObject : IComponentData { public Entity Entity; // 或者用WeakReferenceGameObject但管理更复杂 // 通常我们存储一个GameObject的InstanceID然后通过资源管理器查找。 } // 在Authoring的Bake方法中可以获取GameObject的引用并存储。MonoBehaviour - ECS在MonoBehaviour中可以将输入、事件等数据写入一个ECS单例组件Singleton然后由ECS系统去读取和处理。例如创建一个InputData单例组件在MonoBehaviour的Update中填充它然后在ECS的InputSystem中消费它。// 定义单例组件 public struct InputData : IComponentData { public float2 Move; public bool JumpPressed; } // 在MonoBehaviour中需要获取EntityManager引用 var inputEntity entityManager.CreateEntityQuery(typeof(InputData)).GetSingletonEntity(); entityManager.SetComponentData(inputEntity, new InputData { Move new float2(x, y), JumpPressed Input.GetKeyDown(KeyCode.Space) });个人体会混合架构的边界设计是关键。要明确哪些模块必须用ECS如大量单位的AI、物理、战斗计算哪些用MonoBehaviour更合适如UI、游戏流程控制、第三方插件集成。两者之间通过清晰定义的“桥梁”如单例组件、同步组件进行数据交换保持松耦合。6. 性能分析与调试工具链优化离不开测量。Unity提供了一套强大的工具来分析DOTS应用的性能。Entity Debugger(Window Analysis Entity Debugger)这是你的第一站。可以查看所有World、System、Entity、Archetype和Chunk。检查实体是否按预期创建和销毁组件是否正确添加原型是否过多可能导致Chunk利用率低。Unity Profiler必须使用Deep Profiling来捕获Job和Burst编译代码的详细信息。在Profiler中关注主线程是否有耗时的非Job代码Job线程你的并行Job是否均匀地分布在工作线程上是否有长时间运行的JobBurst编译编译是否成功是否有回退到托管代码的情况内存关注WorldAllocator和TempJobAllocator的分配情况。避免在每帧的OnUpdate中分配托管内存如new List。Burst Inspector(Jobs Burst Open Inspector)可以查看Burst编译器为你的Job生成的汇编代码。对于追求极致性能的模块高级开发者可以通过它来了解编译优化情况。System Logging可以在System中谨慎地使用Debug.Log注意性能影响或使用Unity的新的日志系统来输出关键信息帮助理解执行流程。一个常见的性能问题是“主线程等待Job”。在Profiler中如果你看到主线程有很大一段空白或等待时间而Job线程在忙碌这通常是正常的说明工作被很好地并行化了。但如果主线程在等待一个本该很快完成的Job就要检查Job的依赖关系或是否有Job耗时过长。7. 总结与展望ECS不是终点而是起点回顾开头的“守望先锋”案例和我们的性能困局ECS和数据驱动设计提供的是一条通向高性能、高可维护性代码的路径而不是一个即插即用的解决方案。它要求开发者转变思维从“对象能做什么”转向“数据是什么系统如何处理数据”。我的核心建议是循序渐进不要试图一次性将整个项目迁移到ECS。从一个新的、性能敏感的特性如弹幕系统、粒子系统、大批量NPC开始尝试。拥抱混合在可预见的未来混合架构都是Unity开发的主流。学好如何在ECS和GameObject之间搭建桥梁。性能导向不要为了用ECS而用ECS。如果你的游戏对象数量很少比如1000传统的MonoBehaviour可能更简单高效。ECS的优势在于规模1000 10000 100000。持续学习DOTS和ECS仍在快速发展关注Unity官方博客、论坛和开源项目如Unity的示例项目。最后分享一个我最近在做的RTS项目中的小技巧我们使用ECS来处理所有单位的寻路和队形移动。每个单位只是一个带有Position、Destination、MoveSpeed组件的实体。一个UnitMovementSystem并行Job每帧处理成千上万个单位的位置插值。而单位的渲染、音效、个别特殊技能仍然用传统的GameObject来处理通过一个ViewLink组件关联。这套架构让我们的同屏单位数轻松突破5000而CPU依然游刃有余。这就是数据驱动设计带来的实实在在的工程力量。