
1. 项目概述为什么ECS的执行顺序如此关键如果你正在用Unity的ECS实体组件系统做项目并且已经过了“Hello World”阶段那么你大概率已经踩过或者即将踩到一个大坑System的执行顺序。这玩意儿要是没理清楚你的游戏逻辑会变得像一团乱麻各种莫名其妙的Bug层出不穷。比如你的物理系统还没计算完碰撞渲染系统就已经开始画了结果画出来的东西位置是错的又或者你的输入处理系统跑在了角色移动系统之后导致玩家按了按键角色却要等到下一帧才响应手感稀烂。我刚开始用ECS的时候也以为只要把System写出来Unity就会像处理MonoBehaviour的Update一样有个“默认的”、“合理的”顺序。结果被现实狠狠教育了一番。ECS的核心是数据驱动和并行它默认是“无序”的或者说它的“序”需要你显式地、精确地去定义。这既是ECS性能强大的来源也是新手最容易翻车的地方。所以今天我们就来彻底搞懂Unity ECS中System的执行顺序。这不仅仅是一个配置教程更是一次关于如何设计数据流和依赖关系的思维训练。我们会从最基础的更新顺序原理讲起深入到系统组System Group的架构设计最后手把手教你用属性Attribute进行精细化配置。目标是让你看完之后不仅能配置好顺序更能理解为什么要这样配置从而设计出更清晰、更高效的ECS架构。2. 核心原理ECS的“无序”世界与“有序”需求在传统的面向对象编程OOB或Unity的MonoBehaviour体系里执行顺序虽然也有坑比如Awake、Start、Update的调用时机但大体上同一个Update循环里不同脚本的执行顺序是相对模糊且由Unity内部管理的。我们更多是通过帧与帧之间的时序Update-LateUpdate来组织逻辑。但ECS截然不同。它把数据Component和处理逻辑System彻底分离。System的本质是一个个“数据处理器”它们遍历具有特定组件组合的实体并对其数据进行读写。多个System可能同时读写同一份数据。如果没有明确的执行顺序就会引发经典的“读写冲突”问题。举个例子假设我们有两个SystemMovementSystem读取Velocity速度组件写入Position位置组件。逻辑是Position Velocity * deltaTime。CollisionResponseSystem读取Collision碰撞组件写入Velocity组件。逻辑是碰到墙壁后将Velocity反向。如果CollisionResponseSystem在MovementSystem之后执行那么一帧内发生的过程是MovementSystem用旧的可能包含错误方向的Velocity更新了Position。CollisionResponseSystem检测到碰撞修正了Velocity。 结果是这一帧物体已经用错误的速度移动到了一个错误的位置碰撞响应要等到下一帧才生效。视觉上就是物体“穿墙”了一帧。如果顺序反过来CollisionResponseSystem在MovementSystem之前执行CollisionResponseSystem先修正Velocity。MovementSystem用修正后的、正确的Velocity更新Position。 结果就是正确的碰撞反馈。你看同样的两个System仅仅是执行顺序不同就导致了完全不同的游戏行为。这就是为什么在ECS中我们必须显式地、声明式地定义System之间的依赖关系和执行顺序。Unity ECS提供了两套核心机制来管理这种顺序系统组System Group的层级结构和基于属性的顺序配置。注意这里说的“顺序”主要指的是同一线程内的顺序。ECS同样支持多线程并行的JobSystem但那是在单个System内部对大量数据的并行处理是另一个维度的优化。System与System之间的顺序决定了宏观数据流的正确性。3. 基石理解系统组System Group的层级架构系统组是Unity ECS管理执行顺序的骨架和核心容器。你可以把它想象成一个文件夹或者一个执行阶段。Unity内置了一个默认的世界World这个世界里预定义了几个顶级的系统组形成了游戏循环的基本框架。3.1 默认的系统组层级当你创建一个默认的ECS世界时通常会看到类似这样的层级结构不同Unity版本可能略有差异但思想一致InitializationSystemGroup初始化组。在游戏开始时运行一次用于创建初始实体、加载数据等。SimulationSystemGroup模拟组。这是游戏逻辑的核心每帧都会运行。我们大部分的Gameplay系统如输入、AI、物理、移动都应该放在这里或其子组里。PresentationSystemGroup呈现组。在模拟组之后运行主要负责与渲染相关的逻辑如计算渲染矩阵、同步渲染代理等。它确保在渲染之前所有物体的最终状态都已准备就绪。这三个顶级组本身也是System它们的执行顺序是固定的Initialization-Simulation-Presentation。这是一个经典的“初始化 - 更新 - 呈现”游戏循环。3.2 子组与自定义组更重要的是系统组可以嵌套。SimulationSystemGroup下通常预定义了子组例如FixedStepSimulationSystemGroup固定时间步长模拟组。用于物理等需要稳定迭代的系统。LateSimulationSystemGroup后期模拟组。在主要模拟逻辑之后运行。你也可以创建自己的自定义系统组。为什么要自定义为了更好的逻辑分层。比如你可以创建一个GameplayLogicGroup放在SimulationSystemGroup下然后把所有游戏玩法相关的SystemInputSystemAbilitySystemInventorySystem都放进去。这样你可以整体控制GameplayLogicGroup的执行顺序比如确保它在物理模拟之后而组内System的顺序再单独管理。创建自定义系统组非常简单using Unity.Entities; using Unity.Entities.Systems; // 定义一个自定义系统组 [UpdateInGroup(typeof(SimulationSystemGroup))] // 指定父组 public partial class MyCustomGameplayGroup : ComponentSystemGroup { // 这个类本身就是一个容器可以空着也可以添加一些组级别的逻辑 } // 然后你的System可以指定在这个组里运行 [UpdateInGroup(typeof(MyCustomGameplayGroup))] public partial class MyInputSystem : SystemBase { protected override void OnUpdate() { // ... 处理输入逻辑 } }通过[UpdateInGroup]属性你将MyInputSystem声明为MyCustomGameplayGroup的成员。MyCustomGameplayGroup的执行顺序由其父组SimulationSystemGroup决定而MyInputSystem在MyCustomGameplayGroup内部的顺序则需要进一步的属性来定义。4. 精控使用属性Attribute配置执行顺序系统组划分了大的执行阶段而属性则用于在组内进行精细的排序。这是解决“读写冲突”和定义数据流的关键。4.1 核心排序属性Unity ECS提供了几个核心属性来控制顺序它们通常组合使用[UpdateInGroup]基础属性声明该系统在哪个组中运行。这是必须的除非你接受默认的SimulationSystemGroup。它是排序的前提。[UpdateInGroup(typeof(SimulationSystemGroup))] public partial class MySystem : SystemBase { }[UpdateBefore]和[UpdateAfter]相对顺序属性。用于指定当前系统在另一个系统之前或之后运行。这是最直接、最常用的定义依赖关系的方式。// 系统A [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateBefore(typeof(SystemB))] // 明确声明在SystemB之前运行 public partial class SystemA : SystemBase { } // 系统B [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateAfter(typeof(SystemA))] // 明确声明在SystemA之后运行与上面的声明等价且互斥通常只需在一处声明 public partial class SystemB : SystemBase { }使用技巧通常建议只在依赖关系的一端进行声明比如只在SystemA上标记[UpdateBefore(typeof(SystemB))]以避免循环依赖声明导致Unity报错。声明在“生产者”系统上产生数据的系统往往更符合直觉。[CreateAfter]和[CreateBefore]创建顺序属性。这个很容易被忽略但它非常重要。它控制的是系统实例被创建和初始化的顺序而不是每帧OnUpdate的执行顺序。这会影响系统在OnCreate和OnStartRunning中的行为比如一个系统依赖另一个系统在初始化时创建的Singleton实体。[UpdateInGroup(typeof(InitializationSystemGroup))] [CreateBefore(typeof(NeedConfigSystem))] // 确保我比NeedConfigSystem先被创建 public partial class ConfigLoadingSystem : SystemBase { protected override void OnCreate() { // 在这里创建并初始化一个包含配置数据的Singleton实体 EntityManager.CreateEntity(typeof(GameConfigData)); } } [UpdateInGroup(typeof(InitializationSystemGroup))] public partial class NeedConfigSystem : SystemBase { protected override void OnStartRunning() { // 在这里可以安全地查询ConfigLoadingSystem创建的GameConfigData实体 // 因为CreateBefore保证了ConfigLoadingSystem的OnCreate先执行 } }4.2 高级属性与技巧[RequireMatchingQueriesForUpdate]这个属性本身不控制顺序但它影响系统是否执行。如果一个系统标记了此属性只有当它拥有至少一个有效的查询即存在符合其查询条件的实体时它的OnUpdate才会被调用。这可以用于避免某些“空转”的系统消耗性能但要注意它可能间接影响你对执行顺序的观察因为不运行的系统自然不会参与排序。[DisableAutoCreation]标记此属性的系统不会自动被创建和添加到默认世界。你需要手动在Bootstrap代码中创建并插入它。这给了你终极的控制权可以动态地添加、移除系统或者将其插入到非常特定的位置。对于插件系统或可选的游戏模块非常有用。排序的优先级与解析规则 Unity ECS内部有一个排序解析过程。当你有多个[UpdateBefore/After]声明时它们必须能形成一个有向无环图DAG。如果有循环依赖A在B前B在C前C又在A前Unity会在运行时抛出异常。 排序的优先级大致是首先根据[UpdateInGroup]确定系统在哪个组里。然后在组内根据[UpdateBefore/After]声明的依赖关系进行拓扑排序。对于没有声明依赖关系的系统它们的执行顺序是未定义的虽然通常按创建或发现顺序但绝不能依赖于此。5. 实战设计一个角色控制系统的执行顺序光说不练假把式我们用一个经典的第三人称角色控制器为例来设计一套System执行顺序。场景玩家控制一个角色涉及输入、动画状态机、角色移动、摄像机跟随、物理碰撞检测与响应。目标设计一个正确、高效的数据流。5.1 第一步划分系统组我们决定将所有逻辑放在SimulationSystemGroup下并创建一个子组PlayerLoopGroup来容纳所有玩家相关的系统以便于管理。// 自定义玩家循环组 [UpdateInGroup(typeof(SimulationSystemGroup))] public partial class PlayerLoopGroup : ComponentSystemGroup { }5.2 第二步定义各个System及其职责PlayerInputSystem读取原始输入设备键盘、手柄数据将其转换为通用的输入命令如MoveInput,JumpPressed等写入一个Singleton组件PlayerInputData。PlayerAnimationStateSystem根据当前的角色状态是否在地面、速度大小等和输入命令决定下一帧的动画状态Idle, Walk, Run, JumpStart, JumpLoop, JumpEnd写入AnimationState组件。它依赖于PlayerInputData和来自物理系统的GroundCheck数据。PlayerMovementSystem根据输入命令PlayerInputData和当前的动画状态可能影响移动速度计算角色的期望速度DesiredVelocity。它处理基本的移动逻辑但不直接应用物理。CharacterControllerSystem这是一个“黑盒”物理系统可能是Unity的CharacterController组件在ECS下的封装。它读取DesiredVelocity并考虑碰撞体计算出最终被物理环境约束后的实际速度ActualVelocity和位移然后更新角色的Position和Rotation。它也会生成碰撞事件或写入GroundCheck数据。CameraFollowSystem在角色位置最终确定后根据角色的Position和Rotation平滑地更新摄像机的位置和朝向。它必须跑在所有影响角色位置的系统之后。PlayerStateSyncSystem可选如果动画系统在渲染世界而逻辑在主世界这个系统负责将最终的Position、Rotation和AnimationState同步到渲染代理实体。5.3 第三步用属性定义依赖关系现在我们用属性来编码这些依赖关系。// --- 在 PlayerLoopGroup 内 --- // 1. 输入最先处理因为它不依赖任何其他玩家系统只为其他系统提供数据。 [UpdateInGroup(typeof(PlayerLoopGroup))] public partial class PlayerInputSystem : SystemBase { } // 2. 动画状态机需要输入和地面检测结果。 [UpdateInGroup(typeof(PlayerLoopGroup))] [UpdateAfter(typeof(PlayerInputSystem))] [UpdateAfter(typeof(CharacterControllerSystem))] // 等待物理系统更新地面状态 public partial class PlayerAnimationStateSystem : SystemBase { } // 3. 移动系统需要输入但不一定需要当前动画状态除非动画状态影响移动参数。 // 这里假设移动计算只需要输入。 [UpdateInGroup(typeof(PlayerLoopGroup))] [UpdateAfter(typeof(PlayerInputSystem))] public partial class PlayerMovementSystem : SystemBase { } // 4. 角色控制器是核心物理模拟。它需要移动系统计算出的期望速度。 // 它在移动系统之后运行并产生最终位置和碰撞信息。 [UpdateInGroup(typeof(PlayerLoopGroup))] [UpdateAfter(typeof(PlayerMovementSystem))] public partial class CharacterControllerSystem : SystemBase { } // 5. 摄像机跟随必须在所有影响位置的系统之后运行。 [UpdateInGroup(typeof(PlayerLoopGroup))] [UpdateAfter(typeof(CharacterControllerSystem))] public partial class CameraFollowSystem : SystemBase { } // 6. 状态同步系统必须在所有逻辑更新完成后运行确保数据是最新的。 [UpdateInGroup(typeof(PresentationSystemGroup))] // 注意它属于呈现组确保在渲染前同步 [UpdateBefore(typeof(TransformSystemGroup))] // 在Unity的变换矩阵计算系统之前同步 public partial class PlayerStateSyncSystem : SystemBase { }关键点分析PlayerAnimationStateSystem同时依赖PlayerInputSystem和CharacterControllerSystem。这没问题只要这两个被依赖的系统本身没有循环依赖。这里CharacterControllerSystem又依赖PlayerMovementSystem而PlayerMovementSystem依赖PlayerInputSystem形成了一个清晰的链。PlayerStateSyncSystem被移出了PlayerLoopGroup放入了PresentationSystemGroup。这是非常关键的一步因为它负责将数据同步给渲染侧必须在所有游戏逻辑模拟SimulationSystemGroup完成之后在渲染前PresentationSystemGroup进行。这体现了跨大组Group的排序思想。我们只在必要的地方声明了[UpdateAfter]。例如没有系统声明在CameraFollowSystem之后因为它通常是这个逻辑链的末端。5.4 第四步处理与引擎固定系统的顺序你的自定义系统还需要与Unity引擎内置的系统如TransformSystemGroup协调。TransformSystemGroup在PresentationSystemGroup中运行负责计算最终的渲染矩阵。如果你的CameraFollowSystem修改了摄像机实体的LocalTransform那么它必须在TransformSystemGroup之前运行否则修改会在同一帧被应用吗不一定这取决于CameraFollowSystem在哪个组。更安全的做法是让CameraFollowSystem也作用于渲染代理实体并确保它在PlayerStateSyncSystem之后、TransformSystemGroup之前运行。但这样CameraFollowSystem也需要放在PresentationSystemGroup。这引出了一个更清晰的设计模式将逻辑计算和渲染同步分离。优化后的设计逻辑组 (PlayerLoopGroup):PlayerInputSystem-PlayerMovementSystem-CharacterControllerSystemCharacterControllerSystem同时产生GroundCheck数据。PlayerAnimationStateSystem(依赖PlayerInputSystem和CharacterControllerSystem)。这些系统只更新逻辑世界的组件。同步系统 (PlayerStateSyncSystem):位于PresentationSystemGroup开头。将逻辑世界的Position,Rotation,AnimationState一次性复制到渲染代理实体的对应组件中。渲染组 (PresentationSystemGroup):PlayerStateSyncSystem(同步数据)CameraFollowSystem(现在它读取和写入的是渲染代理实体的LocalTransform数据来源是同步过来的角色位置)TransformSystemGroup(引擎内置计算矩阵)这样的划分使得数据流变成“逻辑计算 - 同步 - 渲染后处理”层次更加清晰也更容易管理跨世界的依赖。6. 调试与排查当顺序出错时怎么办即使设计得再仔细运行时顺序问题依然可能出现。症状通常表现为数据时对时错、某些效果偶尔失效、实体状态不一致。6.1 调试工具与方法System列表查看在Unity Editor的Entities窗口Window - Analysis - Entities中选择Systems标签页。这里会列出当前世界中的所有系统并按它们的执行顺序组层级和组内顺序排列。这是最直观的检查手段。确保你的系统出现在你期望的组里并且顺序符合你的[UpdateBefore/After]声明。使用Debug.Log或UnityEngine.Debug.Log在每个系统的OnUpdate开始处打印一条信息包含系统名和Time.frameCount。运行游戏观察控制台的输出顺序。这是最原始的但非常有效的方法。检查循环依赖错误如果Unity在创建系统时抛出关于循环依赖的异常它会明确指出哪些系统之间形成了环。仔细检查这些系统的[UpdateBefore/After]属性消除循环。依赖关系可视化手动对于复杂项目可以在设计阶段画一张有向图。每个System是一个节点每条[UpdateBefore]或[UpdateAfter]是一条有向边。确保这张图是无环的。6.2 常见问题与解决方案问题现象可能原因排查步骤与解决方案某些系统偶尔不执行1. 系统被放在了错误的组如应在Simulation却放在了Initialization。2. 系统标记了[RequireMatchingQueriesForUpdate]但查询始终为空。1. 在Entities窗口的Systems页签确认系统所在组。2. 检查系统的EntityQuery条件是否太苛刻或实体是否真的拥有所需组件。数据看起来慢一帧经典的顺序错误。消费者系统跑在了生产者系统之前。使用调试打印确认两个系统的执行顺序。为消费者系统添加[UpdateAfter(typeof(ProducerSystem))]。修改了属性但顺序没变1. 代码未重新编译。2. 属性拼写错误或作用对象错误如放在了类字段上。3. 存在多个顺序声明冲突Unity可能无法解析。1. 确保编译无错误。2. 检查属性是否正确地放在SystemBase派生类上。3. 简化依赖声明尽量保持单向链式依赖。与引擎内置系统顺序问题自定义系统需要与TransformSystemGroup、EndSimulationEntityCommandBufferSystem等协作。明确你的系统是提供数据给它们还是消费它们的数据。提供者应在消费者之前。研究内置系统所在的组用[UpdateInGroup]和[UpdateBefore/After]精确调整。例如生成实体的系统应在其命令缓冲区系统之前运行。6.3 一个关于EntityCommandBufferSystem的特别提醒EntityCommandBufferSystem(ECB System) 是ECS中用于延迟执行结构性更改创建/销毁实体、添加/移除组件的系统。Unity提供了几个预定义的ECB系统如BeginSimulationEntityCommandBufferSystem、EndSimulationEntityCommandBufferSystem等。黄金法则你的系统必须在你所使用的ECB系统之前运行并且必须从正确的ECB系统中获取EntityCommandBuffer。例如如果你在SimulationSystemGroup中的某个系统里进行结构性更改你应该public partial class MySpawningSystem : SystemBase { private BeginSimulationEntityCommandBufferSystem m_ECBSystem; protected override void OnCreate() { // 获取对ECB系统的引用 m_ECBSystem World.GetOrCreateSystemBeginSimulationEntityCommandBufferSystem(); } protected override void OnUpdate() { // 从ECB系统获取一个本帧可用的命令缓冲区 var ecb m_ECBSystem.CreateCommandBuffer(); // ... 使用ecb进行创建实体等操作 // **不需要手动将ecb添加回系统CreateCommandBuffer()已经处理了** } }BeginSimulationEntityCommandBufferSystem会在SimulationSystemGroup的开始阶段就执行刷新命令缓冲区池。而你的MySpawningSystem通过[UpdateBefore(typeof(BeginSimulationEntityCommandBufferSystem))]来确保它在ECB系统之前运行这样它记录的指令会在下一帧的BeginSimulationEntityCommandBufferSystem执行时被播放。如果你错误地将系统放在了ECB系统之后那么你记录的指令可能会被延迟一帧甚至丢失导致难以调试的Bug。7. 架构思维超越配置设计清晰的数据流掌握了配置顺序的工具后更重要的是培养一种“数据流驱动”的架构思维。不要仅仅把System看成是一堆需要排序的Update函数而要把它们看成是数据处理管道上的一个个节点。设计建议明确数据生产者与消费者每个System都应该有清晰的输入它读取哪些组件和输出它写入哪些组件。在代码注释或设计文档中写明这一点。依赖方向即数据流方向[UpdateAfter]应该指向数据的生产者。这使依赖关系图与数据流图一致更易于理解。拥抱“单次遍历”思想如果一个System只是简单地将组件A的数据复制到组件B且没有其他逻辑考虑是否可以合并到另一个System中或者使用IJobEntity在单个Job中完成减少System数量简化排序复杂度。利用系统组进行模块化将功能紧密相关的System放在同一个自定义组里。这样你可以通过调整整个组的顺序[UpdateInGroup]来模块化地管理功能块而不是调整组内每一个System。为“阶段”而非“功能”命名组组名应体现其执行阶段如FixedUpdateGroup,PreRenderGroup而不是功能如PhysicsGroup,RenderingGroup。因为一个功能如物理可能包含多个阶段碰撞检测、求解、积分它们需要放在不同的主组里。最后一点个人心得ECS项目初期不要过度设计System的粒度。可以先让逻辑跑起来然后观察性能剖析器Profiler和逻辑依赖。当发现某些System总是需要特定的执行顺序或者某些数据被多个System频繁读写时再考虑重构和细化System的职责并为其添加强制的顺序约束。过早的、过度的排序约束会让架构变得僵化。记住ECS的灵活性在于数据而不是System的排列组合。一个好的ECS架构其System执行顺序图应该是清晰、简单、易于推理的它直接反映了你游戏核心数据流的脉络。