Unity DOTS实战:ECS架构与Job System实现万人同屏性能优化

📅 2026/7/11 4:14:05
Unity DOTS实战:ECS架构与Job System实现万人同屏性能优化
1. 项目概述为什么是DOTS与万人同屏如果你做过Unity项目尤其是涉及大量单位比如RTS、MMO、大战场的战斗场景肯定遇到过性能瓶颈。当屏幕上同时出现几百个带动画、有AI的单位时帧率就开始“跳水”了。传统的GameObject MonoBehaviour架构每个单位都是一个独立的游戏对象背后是复杂的组件树和序列化开销CPU和内存的消耗是指数级增长的。当数量突破一千甚至向“万人”迈进时传统架构基本就“趴窝”了。这就是为什么我们需要DOTSData-Oriented Technology Stack面向数据的技术栈。它不是一个单一功能而是一套彻底改变Unity开发范式的技术集合核心目标是释放多核CPU的性能潜力处理海量实体。我最近就在用DOTS重构一个大型战斗演示场景目标就是实现“万人同屏”还能流畅运行。这听起来有点“唬人”但DOTS确实给了我们实现这个目标的硬核工具。这个系列文章我会把我从项目配置、ECS架构设计、到性能调优的完整踩坑过程记录下来。今天这第一篇我们先从最基础的“地基”打起环境配置、核心概念理解以及如何用ECS的思维设计你的第一个“战斗单位”。简单来说DOTS包含三大核心支柱ECS实体组件系统、Burst编译器和Job System。ECS负责以高效的数据布局来组织你的游戏逻辑Burst能将C#代码编译成高度优化的原生机器码Job System则安全、方便地让你进行多线程并行计算。三者结合才能发挥最大威力。所以别指望只开个Burst或者只用Jobs就能解决所有问题这是一个系统工程。2. 环境配置与核心插件版本锁定工欲善其事必先利其器。DOTS目前仍处于快速迭代期不同版本间的兼容性是个大问题。我强烈建议你锁定以下版本这是我经过多次“血泪”测试后找到的稳定组合能避开很多已知的坑。编辑器版本Unity 2022.3 LTS我使用的是2022.3.17f1c1。选择LTS长期支持版本是生产项目的黄金准则它比Tech Stream技术流版本稳定得多。2022.3对DOTS的支持已经相当成熟且相关的Package Manager生态也稳定。不要盲目追求最新版新版本可能引入未知的API变动或Bug。核心DOTS插件包版本你需要通过Window Package Manager切换到“Unity Registry”或“My Registries”来查找和安装以下包。务必在安装时选择特定版本不要使用“Latest”。Entities (Unity.Entities): 1.0.0-pre.15这是ECS的核心运行时库。1.0.0-pre.15是一个功能完整且相对稳定的预览版。安装后你的项目结构里会出现“DOTS”菜单。Burst: 1.8.3Burst编译器。这个版本与Entities 1.0.0-pre.15兼容性良好。Burst负责将你的Job代码编译成SIMD指令集优化的原生代码是性能提升的关键。Collections: 2.1.4提供了高性能的无托管Unmanaged集合类型如NativeArray、NativeList等是Job System中传递数据的主要容器。Mathematics: 1.2.6提供了SIMD友好的数学库如float3,quaternion,math函数替代Unity原有的Vector3和Quaternion在Burst编译后性能极高。安装与验证步骤新建一个空的3DURP或Built-in项目。我个人更推荐从URP开始因为图形管线更现代且DOTS的Hybrid Renderer与URP集成更好。打开Package Manager按上述版本号逐一安装。安装Entities包时可能会提示自动安装其依赖如Collections、Mathematics请检查依赖版本是否与上述一致不一致则手动调整。安装完成后创建一个空的场景。在菜单栏点击DOTS Convert SubScene。在Hierarchy中选中一个空的GameObject右键选择Convert To SubScene。你会看到该物体变成了一个蓝色的立方体图标。这是DOTS的核心SubScene。它就像一个“传送门”在编辑时我们用熟悉的GameObject来搭建场景在运行时SubScene会将内部的GameObject批量转换成高效的ECS数据。这是连接传统工作流和DOTS世界的桥梁。注意如果你在Package Manager里找不到Entities包请确保在Project Settings Package Manager中勾选了“Enable Preview Packages”。因为Entities目前还是预览版。3. ECS核心概念重塑数据驱动思维用惯了MonoBehaviour切换到ECS需要一次彻底的思维转换。别再想着“这个脚本挂载到哪个物体上”而是开始思考“这些数据是什么以及哪些系统来处理这些数据”。3.1 实体Entity、组件Component与系统System实体Entity仅仅是一个ID一个轻量级的标识符。它本身没有任何数据或行为你可以把它想象成数据库里的一条记录的主键。组件Component纯粹的数据结构一个实现了IComponentData接口的struct。它附着在实体上描述实体的某种状态。例如PositionComponent包含float3坐标、HealthComponent包含float血量、MoveSpeedComponent包含float速度。// 一个移动组件的例子 public struct MoveSpeed : IComponentData { public float Value; // 注意ECS组件推荐使用值类型 }系统System继承自SystemBase的类包含OnUpdate()方法。系统是逻辑执行者它通过查询Query来寻找拥有特定组件组合的实体然后对这些实体的数据进行处理。例如一个MovementSystem会查询所有同时拥有PositionComponent和MoveSpeedComponent的实体并在每帧更新它们的位置。3.2 原型Archetype与块Chunk这是ECS性能的魔法所在。原型Archetype一组实体所拥有的组件类型的唯一组合。例如所有拥有Position和MoveSpeed的实体属于一个原型如果再增加一个Health组件就属于另一个不同的原型。块Chunk内存块每个块只存储同一种原型的实体的数据。所有实体的Position数据在内存中是连续存储的所有MoveSpeed数据也是连续存储的。这种“按列存储”的方式对CPU缓存极其友好。当系统迭代处理数据时它是在连续的内存块上高速前进这就是著名的数据局部性优势。3.3 用ECS思维设计“战斗单位”假设我们要为一个简单的“士兵”单位创建数据。 传统思维一个Soldier预制体上面挂着SoldierController脚本脚本里有hp、speed、transform等字段。 ECS思维我们拆解出这个士兵在系统中需要被处理的数据和逻辑。数据组件LocalTransform(Unity自带)位置、旋转、缩放。Health当前生命值、最大生命值。MoveSpeed移动速度。AttackTarget一个Entity类型的字段指向它要攻击的目标实体。SoldierTag一个IComponentData接口的空结构体Tag Component。它没有数据仅用于标记“这是一个士兵实体”方便系统查询。逻辑系统SoldierMovementSystem负责移动。查询所有有LocalTransform、MoveSpeed和SoldierTag的实体根据速度更新位置。SoldierAttackSystem负责攻击。查询所有有AttackTarget和SoldierTag的实体检查距离冷却时间然后对目标的Health组件进行修改。SoldierDeathSystem负责死亡。查询所有Health值小于等于0且拥有SoldierTag的实体销毁它们或播放死亡动画。你看逻辑被清晰地分离了。数据是冰冷的、被动的结构系统是火热的、主动的处理器。这种分离让并行化变得异常简单。4. 创建第一个DOTS单位从GameObject到Entity理论说再多不如动手。我们来创建一个会原地旋转的立方体这是DOTS的“Hello World”。4.1 创建可转换的GameObject在场景中创建一个Cube。我们需要为它添加ECS能识别的组件。在Inspector窗口点击Add Component搜索并添加Convert To Entity。这个组件告诉Unity这个GameObject在运行时需要被转换成ECS实体。仅添加“Convert To Entity”还不够因为它只提供了转换机制但没有定义转换后实体该有什么数据。我们还需要添加代表数据的MonoBehaviour组件。再次点击Add Component搜索并添加Authoring相关的组件。例如为了有移动能力我们可以添加一个脚本先创建// RotationSpeedAuthoring.cs using Unity.Entities; using UnityEngine; public class RotationSpeedAuthoring : MonoBehaviour { public float DegreesPerSecond 360.0f; // 这个Baker类在构建或进入运行模式时被调用 class Baker : BakerRotationSpeedAuthoring { public override void Bake(RotationSpeedAuthoring authoring) { var entity GetEntity(TransformUsageFlags.Dynamic); // 将MonoBehaviour的数据“烘焙”成一个ECS组件添加到实体上 AddComponent(entity, new RotationSpeed { RadiansPerSecond math.radians(authoring.DegreesPerSecond) }); } } } // 这是对应的ECS组件数据 public struct RotationSpeed : IComponentData { public float RadiansPerSecond; }将RotationSpeedAuthoring脚本拖到Cube上。现在你的Cube拥有了一个Convert To Entity和一个RotationSpeedAuthoring组件。4.2 创建处理旋转的系统系统是独立于场景存在的通常我们把它放在一个程序集定义Assembly Definition中以便管理。在Project窗口右键Create C# Script命名为RotationSystem.cs。using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; // 部分类允许将系统分散在多个文件 public partial struct RotationSystem : ISystem { // 可选系统创建时调用一次 [BurstCompile] public void OnCreate(ref SystemState state) { } // 可选系统销毁时调用一次 [BurstCompile] public void OnDestroy(ref SystemState state) { } // 每帧调用 [BurstCompile] public void OnUpdate(ref SystemState state) { // 通过SystemAPI.Query来查询所有拥有LocalTransform和RotationSpeed组件的实体 // 使用“ref”来修改LocalTransform因为它是可写的 foreach (var (transform, speed) in SystemAPI.QueryRefRWLocalTransform, RefRORotationSpeed()) { // 计算这一帧的旋转增量 float deltaTime SystemAPI.Time.DeltaTime; quaternion deltaRotation quaternion.RotateY(speed.ValueRO.RadiansPerSecond * deltaTime); // 应用旋转 transform.ValueRW.Rotation math.mul(transform.ValueRW.Rotation, deltaRotation); } } }注意我们使用了RefRW可读写引用和RefRO只读引用。这是新的SystemAPI.Query语法糖更安全便捷。LocalTransform是Unity.Transforms包提供的标准组件。4.3 放入SubScene并运行在Hierarchy中创建一个空GameObject命名为“DOTS_Scene”。选中它右键选择Convert To SubScene。你会看到它变成了蓝色立方体图标。将我们刚才创建的Cube拖拽成为这个SubScene的子物体。点击运行。如果一切正常Cube应该开始绕Y轴旋转。在Entities窗口Window Entities Entities中你可以实时看到场景中所有的实体及其组件。实操心得很多新手在这一步会遇到“系统不执行”的问题。请检查1. 系统类是否继承了ISystem或SystemBase2. 系统代码是否在编译后的程序集中确保没有编译错误。3. 实体是否真的被正确转换查看Entities窗口确认实体存在且拥有LocalTransform和RotationSpeed组件。4. 确保SubScene已正确加载。有时需要确保SubScene的加载部分在运行模式下是激活的。5. 实现万人同屏的基石实例化与数据生成单个立方体旋转只是开始。“万人同屏”意味着我们要在运行时创建和管理海量实体。用Instantiate预制体那又回到了老路。在DOTS中我们使用实体预制体Entity Prefab和EntityManager来批量生成。5.1 创建实体预制体将我们刚才制作好的Cube带有Convert To Entity和Authoring组件从Hierarchy拖到Project窗口创建一个预制体命名为“SoldierPrefab”。从Hierarchy中删除原始的Cube。选中这个预制体在Inspector中确保它的Convert To Entity组件上Convert And Inject On Game Object Play选项是勾选的。同时你可以勾选Dont Destroy On Load这会让它在转换后成为一个Entity Prefab。我们需要一个脚本来在运行时生成这个预制体。创建一个新的Authoring脚本SpawnerAuthoring.cs。using Unity.Entities; using UnityEngine; public class SpawnerAuthoring : MonoBehaviour { public GameObject Prefab; // 拖入我们刚创建的SoldierPrefab public int Count 10000; // 要生成的数量我们先试试1万 class Baker : BakerSpawnerAuthoring { public override void Bake(SpawnerAuthoring authoring) { var entity GetEntity(TransformUsageFlags.None); AddComponent(entity, new Spawner { Prefab GetEntity(authoring.Prefab, TransformUsageFlags.Dynamic), Count authoring.Count }); } } } public struct Spawner : IComponentData { public Entity Prefab; public int Count; }5.2 创建生成系统这个系统需要在游戏开始时运行一次生成所有实体。我们可以把它放在一个ISystem的OnUpdate里但只执行一次。using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Transforms; using UnityEngine; // 部分类系统可以拆分 public partial struct SpawnSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { // 我们要求这个系统在Spawner组件存在后才执行 state.RequireForUpdateSpawner(); } [BurstCompile] public void OnUpdate(ref SystemState state) { // 因为Spawner我们可能只需要一个用单例查询。生成完成后我们会销毁这个Spawner组件。 var spawner SystemAPI.GetSingletonSpawner(); // 使用EntityManager来批量实例化实体 // 这是最直接的方式但注意EntityManager.Instantiate是主线程操作。 // 对于万人级别在主线程瞬间生成一万个实体可能会造成卡顿。 // 更高级的做法是使用EntityCommandBuffer和并行化我们下一篇会讲。 var entities new NativeArrayEntity(spawner.Count, Allocator.Temp); state.EntityManager.Instantiate(spawner.Prefab, entities); // 为了演示我们可以为每个实体设置一个随机的位置 var random Random.CreateFromIndex(1234); for (int i 0; i entities.Length; i) { var transform SystemAPI.GetComponentRWLocalTransform(entities[i]); transform.ValueRW.Position new float3(random.NextFloat(-50, 50), 0, random.NextFloat(-50, 50)); } entities.Dispose(); // 记得释放NativeArray // 生成完成后销毁Spawner实体或移除Spawner组件防止下一帧再次生成 var spawnerEntity SystemAPI.GetSingletonEntitySpawner(); state.EntityManager.DestroyEntity(spawnerEntity); // 关闭这个系统因为它只需要运行一次 state.Enabled false; Debug.Log($已生成 {spawner.Count} 个实体。); } }5.3 配置场景并运行在场景中创建一个空GameObject命名为“Spawner”。挂载SpawnerAuthoring脚本并将Project中的“SoldierPrefab”拖拽到其Prefab字段。设置Count为10000。将这个Spawner也放入之前创建的SubScene中。点击运行。你会在控制台看到日志并且在Game视图和Entities窗口中看到密密麻麻的立方体。尝试在Scene视图中拉远视角感受一下一万个实体同时旋转的场景。如果你的配置正确帧率应该依然保持在一个很高的水平比如60FPS以上。踩坑记录第一次运行时你可能会遇到“Burst compilation failed”的错误。这通常是因为Burst编译器在后台编译Job代码需要时间或者代码中有不支持的语法。检查Console窗口的详细错误信息。常见原因包括在Burst编译的Job中使用了托管类型如string,class、尝试访问EntityManager主线程API等。确保你的OnUpdate方法里尤其是循环体内的代码只使用值类型和Burst支持的内置函数。6. 性能初探与基础优化思路现在我们有了一万个旋转的立方体但这离“战斗场景”还很远。不过我们已经可以开始观察和思考性能问题了。6.1 使用Profiler与Entities窗口分析Unity Profiler (Window Analysis Profiler)切换到Profiler注意CPU使用率。你应该能看到一个名为“RotationSystem”的项它代表了我们的旋转系统开销。由于我们使用了foreach查询它默认在主线程运行。对于一万个实体开销可能已经显现。Entities窗口 (Window Entities Entities)这里可以查看所有原型和实体。注意观察“Soldier”实体所属的原型以及它们占用的块Chunk数量。理解数据是如何在内存中组织的。6.2 从主线程到多线程引入IJobEntity我们当前的RotationSystem是单线程的。要发挥DOTS的真正威力必须利用多核CPU。我们需要将工作卸载到Job中。修改RotationSystem.cs使用IJobEntityusing Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; // 定义一个Job [BurstCompile] public partial struct RotationJob : IJobEntity { public float DeltaTime; // 这个方法会为每个匹配的实体执行一次 void Execute(ref LocalTransform transform, in RotationSpeed speed) { quaternion deltaRotation quaternion.RotateY(speed.RadiansPerSecond * DeltaTime); transform.Rotation math.mul(transform.Rotation, deltaRotation); } } // 更新系统来调度这个Job public partial struct RotationSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { var job new RotationJob { DeltaTime SystemAPI.Time.DeltaTime }; // 默认情况下ScheduleParallel会尝试并行执行如果安全的话 job.ScheduleParallel(); // 注意ScheduleParallel返回一个JobHandle通常需要存储或组合。 // 在这个简单系统里我们依赖Unity的默认依赖管理系统。 // 更复杂的场景需要使用Dependency属性来管理。 } }关键变化我们创建了一个RotationJob结构体实现了IJobEntity接口。它的Execute方法定义了每个实体要做的操作。在系统的OnUpdate中我们创建这个Job实例并调用ScheduleParallel()。这个方法会自动分析数据依赖并尝试将工作分割到多个CPU核心上并行执行。IJobEntity会自动生成查询代码匹配所有同时拥有LocalTransform和RotationSpeed的实体。再次运行并打开Profiler。你应该能看到“RotationJob”相关的项并且CPU使用可能被分散到多个线程上主线程的负担减轻整体帧时间可能缩短。6.3 管理Job依赖在上面的简单例子中我们直接调度了Job没有处理它的JobHandle。在真实项目中多个系统可能读写相同的数据必须正确处理依赖关系否则会导致竞态条件Race Condition。Unity ECS通过SystemState的Dependency属性来管理。一个更规范的写法是public partial struct RotationSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { var job new RotationJob { DeltaTime SystemAPI.Time.DeltaTime }; // 将Job调度并将返回的JobHandle赋值给系统的Dependency。 // 这样这个系统完成前必须等待这个Job完成。 // 同时这个Job也会等待所有之前依赖它的工作完成。 state.Dependency job.ScheduleParallel(state.Dependency); } }这就是ECS多线程安全的基础。每个系统在调度自己的Job时都会将state.Dependency作为输入并将新的JobHandle输出给它。Unity会自动构建一个正确的依赖关系图。7. 迈向战斗场景组件设计与系统规划有了生成和基础运动的能力我们可以开始规划一个最小化的战斗场景了。我们需要为“士兵”和“敌人”可能只是另一种颜色的立方体定义更多的组件和系统。7.1 扩展战斗组件创建新的组件数据// HealthComponent.cs using System; using Unity.Entities; [Serializable] public struct Health : IComponentData { public float MaxHealth; public float CurrentHealth; } // AttackComponent.cs using Unity.Entities; public struct Attack : IComponentData { public float Damage; public float AttackRange; public float AttackCooldown; // 攻击间隔 public float CurrentCooldown; } // TargetComponent.cs using Unity.Entities; public struct Target : IComponentData { public Entity Value; // 目标实体的ID }为这些组件创建对应的Authoring脚本以便在编辑器中配置预制体。7.2 规划战斗系统一个简化的战斗循环可能包含以下系统按顺序执行TargetSelectionSystem为每个没有目标的战斗单位寻找最近的可攻击目标敌人。这个系统需要遍历所有单位计算距离是计算密集型的必须用Job并行化。MovementSystem根据目标和移动速度向目标移动。如果已在攻击范围内则停止移动。AttackCooldownSystem并行减少所有单位的CurrentCooldown。AttackSystem对于冷却完毕CurrentCooldown 0且目标在攻击范围内的单位执行攻击逻辑减少目标实体的Health.CurrentHealth并重置自身的CurrentCooldown。DeathSystem检查所有单位的Health.CurrentHealth如果0则销毁实体或添加一个DestroyTag组件由另一个清理系统处理。7.3 处理跨实体数据访问AttackSystem面临一个关键挑战它需要读取攻击者的Attack数据并修改目标的Health数据。在Job中如果多个攻击者同时攻击同一个目标就会发生多个线程同时写入目标的Health组件这是不允许的。解决方案是使用ECBEntity Command Buffer或启用命令捕获Enable Command Buffering。更高级的方案是使用IJobChunk进行更细粒度的控制或者将伤害计算改为先收集再应用。例如AttackSystem可以输出一个DamageEvent组件包含目标Entity和伤害值然后由一个单线程的ApplyDamageSystem来统一处理所有伤害事件避免并行写冲突。这涉及到ECS更深入的设计模式我们将在本系列的下一篇中详细展开包括如何高效地处理单位间的交互、空间划分如使用Unity.Physics或SpatialQuery来优化TargetSelectionSystem以及如何组织这些系统的执行顺序。8. 常见问题与排查技巧实录在搭建这个万人同屏Demo的过程中我遇到了不少典型问题这里汇总一下希望能帮你节省时间。8.1 实体没有出现或系统不执行检查SubScene确保你的预制体和生成器都放在了SubScene下面并且SubScene处于激活状态。在编辑器中SubScene有个“Open”按钮点击后可以查看其内部的转换状态。检查Baker代码在Authoring脚本的Baker中GetEntity()和AddComponent()调用是否正确TransformUsageFlags是否合适对于动态移动的物体用Dynamic静态的用None检查系统状态系统是否被禁用在OnCreate中是否错误地设置了state.Enabled false系统代码是否有编译错误查看Entities窗口这是最重要的调试工具。运行游戏后打开它查看是否存在你期望的实体和组件。如果没有说明转换失败。8.2 Burst编译错误错误信息通常会在Console中显示“Burst compilation failed”并附带一堆错误。常见原因在[BurstCompile]的Job或方法中使用了class类、string等托管类型。在Job中尝试访问EntityManager、GameObject等只能在主线程使用的东西。使用了Burst不支持的C#语法或.NET API如LINQ、反射。解决方法将逻辑拆分成Burst兼容的部分和不兼容的部分。不兼容的操作如实例化实体、访问EntityManager放在主线程系统或使用EntityCommandBuffer记录稍后在主线程执行。8.3 性能没有提升甚至下降未使用并行Job确认你的系统是否真的调度了并行JobScheduleParallel而不是主线程循环或单线程JobSchedule。数据布局不佳频繁访问的组件是否在同一个原型中如果两个系统频繁访问的组件被拆分到不同原型会导致缓存命中率低。使用共享组件SharedComponentData和块组件ChunkComponentData要格外小心它们会影响数据布局。存在结构性变化Structural Changes在每帧的OnUpdate中频繁创建/销毁实体、添加/移除组件会导致原型变化触发同步点Sync Point强制所有Job完成严重破坏性能。应使用EntityCommandBuffer将这类操作推迟到帧末批量执行。Profiler分析使用Deep Profiler或Unity的Job Profiler来定位具体是哪个系统或Job耗时最长。可能是你的算法复杂度太高如O(n²)的距离计算需要考虑空间划分算法如网格、四叉树、BVH。8.4 如何调试Job中的数据在Job中不能使用Debug.Log。调试方法有使用NativeArray临时存储在Job中声明一个NativeArray将你想检查的数据写入其中Job完成后在主线程读取并打印。使用Unity.Debug的特殊方法UnityEngine.Debug.Log不能在Job中用但部分Debug.DrawRay或Debug.DrawLine可以在Job中调用需谨慎可能有性能开销用于可视化。禁用Burst暂时移除[BurstCompile]属性并在Player Settings中禁用Burst编译这样可以在Job中使用托管代码调试但性能会回归正常C#水平。从GameObject到一万个流畅旋转的Entity我们完成了DOTS入门的第一步。核心是思维的转变从面向对象到面向数据从单线程到多线程并行。环境配置是基础ECS概念是核心SubScene是桥梁而Job System和Burst则是性能的引擎。在下一篇中我们将深入战斗逻辑的实现处理单位寻敌、移动、攻击和伤害计算并解决多线程下的数据竞争问题让我们这“一万人”真正打起来。届时我们会接触到更复杂的Job调度、命令缓冲CommandBuffer以及可能需要的物理或空间查询模块。