Unity ECS 与 OOP:性能差异、架构取舍与混合实践

Unity ECS 与 OOP:性能差异、架构取舍与混合实践
摘要:本文面向已经接触过 Unity
GameObject、MonoBehaviour,但还不清楚 ECS 到底解决什么问题的开发者。文章会先用人话解释 ECS 与 OOP 的根本区别,再拆开说明为什么 ECS 在大量同类对象、批量模拟和 CPU 热点场景下通常更快。最后会给出实际项目中的选型方式:哪些系统继续用 OOP,哪些系统适合迁到 ECS,以及为什么很多 Unity 项目采用 ECS + OOP 的混合架构,而不是二选一。
一、背景与动机
1.1 先给结论:ECS 快,不是因为名字先进
Unity 游戏开发里说的 ECS,通常指 Entity Component System,中文常译为“实体-组件-系统”。在 Unity 当前官方体系里,它主要落在 Entities package 与 DOTS 生态中。Unity 官方文档将 Entities 描述为 DOTS 的一部分,它提供了数据导向的 ECS 架构实现。
但这里最容易误解的一点是:ECS 并不是天然比 OOP 快。
更准确的说法是:
当你的问题可以被表达成“很多对象拥有相似数据,并且需要被同一套逻辑批量处理”时,ECS 更容易写出 CPU 友好、缓存友好、可多线程、可被 Burst 优化的代码。
换句话说,ECS 快的不是“架构名词”,而是它背后的 Data-Oriented Design,数据导向设计。它让你从“我有一个怪物对象,它会自己移动、攻击、死亡”转向“我有一批 Position、Velocity、Health、AttackTarget 数据,系统按规则批量处理它们”。
这也是为什么 ECS 在以下场景中经常被提起:
- 几千到几十万个单位、子弹、投射物、粒子式对象。
- RTS、塔防、幸存者类、弹幕、模拟经营、群体 AI。
- 大量 Buff、状态、寻路候选、感知、伤害结算。
- 服务器权威模拟、确定性模拟、回滚网络同步。
- CPU 已经成为瓶颈,并且逻辑具有明显的批量特征。
反过来,如果你的项目只有几十个对象,逻辑主要是 UI、剧情、动画、相机、交互机关,强行使用 ECS 往往只会增加心智负担。
1.2 Unity 传统开发本来就有“组件”概念,为什么还需要 ECS
很多人第一次听 ECS 会困惑:Unity 的 GameObject + Component + MonoBehaviour 不也是组件化吗?
确实,Unity 传统工作流也有组件思想:
GameObject: Player
Components:
- Transform
- Animator
- Rigidbody
- PlayerController : MonoBehaviour
- Health : MonoBehaviourUnity 的 MonoBehaviour 官方文档也说明,MonoBehaviour 是可以附加到 GameObject 上的脚本框架,并提供 Start、Update 等事件函数。这个模式非常直观,也非常适合编辑器工作流。
但是传统 GameObject 组件模式与 DOTS/Entities ECS 的关键差异在于:
传统 GameObject 组件:
对象是中心。组件通常既持有数据,也包含行为,运行时围绕对象实例展开。
Entities ECS:
数据是中心。Entity 只是 ID,Component 主要放数据,System 批量处理匹配组件的数据。传统 Unity 组件解决的是“怎么把功能挂到一个场景对象上”;ECS 解决的是“怎么把大量同类数据组织成 CPU 容易处理的形态”。
1.3 本文适用范围
本文基于 Unity 2026 年 6 月可查的官方文档口径整理,重点关注:
- Unity Entities 1.4 文档中的 ECS 概念、Entity、Component、System、Archetype、Structural Change、Job System。
- Unity 6.4 文档中的
MonoBehaviour与传统 GameObject 脚本方式。 - Unity Burst 文档中 Burst 作为高性能 C# 编译器的定位。
不同 Unity 版本、Entities 包版本、编辑器 UI 名称和部分 API 可能变化,具体项目以当前版本实测和项目约束为准。
二、核心概念
2.1 一句话理解 OOP
OOP,Object-Oriented Programming,面向对象编程,是把程序组织成一个个“有数据、有行为、有身份”的对象。
在 Unity 里,一个典型 OOP/MonoBehaviour 风格的怪物可能长这样:
using UnityEngine;
public class Monster : MonoBehaviour
{
public float hp = 100f;
public float speed = 3f;
public Transform target;
private void Update()
{
MoveToTarget();
TryAttack();
CheckDeath();
}
private void MoveToTarget()
{
if (target == null) return;
transform.position = Vector3.MoveTowards(
transform.position,
target.position,
speed * Time.deltaTime
);
}
private void TryAttack()
{
// 攻击逻辑
}
private void CheckDeath()
{
if (hp <= 0f)
{
Destroy(gameObject);
}
}
}这个写法的优点很明显:
- 好理解:怪物就是一个对象,怪物自己移动、攻击、死亡。
- 好调试:点开场景里的怪物,看 Inspector、看组件、看引用。
- 好协作:策划、美术、TA、程序都熟悉 Prefab 和组件工作流。
- 好接 Unity 生态:动画、碰撞、音频、粒子、Timeline、Cinemachine、UI 都很自然。
它的问题也很明显:如果场景里有十万个怪物、子弹或模拟对象,每个对象都各自执行 Update,各自读写分散的数据,性能就可能被内存访问、调度开销、引用跳转、GC 压力拖垮。
2.2 一句话理解 ECS
ECS 是把“对象是谁”“对象有什么数据”“这些数据怎么变化”拆成三层的架构。
三层分别是:
| 概念 | 人话解释 | Unity Entities 中的大致对应 |
|---|---|---|
| Entity | 一个轻量 ID,表示某个东西存在 | Entity |
| Component | 纯数据,描述实体有什么属性 | IComponentData、Buffer、Tag 等 |
| System | 逻辑,批量处理拥有某些组件的实体 | ISystem、SystemBase |
一个 ECS 风格的“移动对象”可以拆成这样:
Entity #1001
Components:
- LocalTransform
- MoveSpeed
- MoveTarget
System:
- MoveToTargetSystem 批量处理所有拥有 LocalTransform + MoveSpeed + MoveTarget 的实体组件示意:
using Unity.Entities;
using Unity.Mathematics;
public struct MoveSpeed : IComponentData
{
public float Value;
}
public struct MoveTarget : IComponentData
{
public float3 Position;
}系统示意:
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
[BurstCompile]
public partial struct MoveToTargetSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
float deltaTime = SystemAPI.Time.DeltaTime;
foreach (var (transform, speed, target) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveSpeed>, RefRO<MoveTarget>>())
{
float3 current = transform.ValueRO.Position;
float3 direction = math.normalizesafe(target.ValueRO.Position - current);
transform.ValueRW.Position = current + direction * speed.ValueRO.Value * deltaTime;
}
}
}注意这个系统没有问“你是什么类”。它只问:
哪些实体同时拥有
LocalTransform、MoveSpeed、MoveTarget?把它们拿出来,批量移动。
这就是 ECS 的核心味道。
2.3 OOP 与 ECS 的核心差异表
| 维度 | OOP / MonoBehaviour | ECS / Entities |
|---|---|---|
| 思考中心 | 对象 | 数据 |
| 数据与行为 | 通常放在同一个类里 | Component 放数据,System 放行为 |
| 复用方式 | 继承、接口、组合、委托 | 增减组件,改变实体能力 |
| 更新方式 | 每个对象自己响应生命周期 | 系统按查询批量处理 |
| 内存形态 | 对象和引用较分散 | 同 archetype 的组件按 chunk 组织 |
| 多线程 | 需要开发者额外设计 | 更容易与 Job System 结合 |
| 优势 | 直观、封装、适合复杂个体 | 批量、高性能、适合大规模模拟 |
| 代价 | 大量对象时容易有性能问题 | 学习成本高,架构约束强 |
2.4 用一个类比讲清楚
OOP 像管理一个个员工档案:
张三档案:姓名、年龄、工资、岗位、涨薪方法、调岗方法
李四档案:姓名、年龄、工资、岗位、涨薪方法、调岗方法
王五档案:姓名、年龄、工资、岗位、涨薪方法、调岗方法你想给所有人涨薪时,要逐个打开每个人的档案。
ECS 像一张表格:
EntityId | Salary | Department | Performance
1001 | 10000 | Combat | A
1002 | 12000 | UI | B
1003 | 9000 | Combat | A你想给所有绩效 A 的人涨薪时,直接扫描对应列,批量修改。
这个类比不完美,但能抓住关键:ECS 更像表格和批处理,OOP 更像一个个有行为的对象。
三、详细讲解
3.1 为什么 ECS 在大量对象时通常更快
3.1.1 CPU 真正怕的不是计算,而是等数据
很多游戏逻辑看上去是在“算”,但现代 CPU 经常不是算不过来,而是在等内存把数据送过来。
假设你要更新 100000 个单位的位置:
position += velocity * deltaTime这行计算本身很便宜。真正影响性能的是:
position在哪里?velocity在哪里?- 下一个单位的数据是不是就在旁边?
- CPU cache 能不能提前把后续数据读进来?
- 有没有大量对象引用、虚调用、托管对象间接访问?
OOP 写法中,一个单位对象可能长这样:
Monster object
- Transform reference
- Animator reference
- AI reference
- Health object
- Buff list
- Target reference
- Inventory reference这些数据不一定连续。CPU 处理一个怪物时,可能刚读了怪物对象,又跳到 Transform,又跳到目标对象,又跳到 Buff 列表。跳来跳去会造成缓存不命中。
ECS 的理想形态是:
Position[]: p1, p2, p3, p4, p5...
Velocity[]: v1, v2, v3, v4, v5...系统处理移动时只需要连续读 Position 和 Velocity。CPU 更容易预取数据,缓存命中率更高。
这就是 ECS 常被说成高性能的第一层原因:它让热数据更集中。
3.1.2 ECS 把“对象 Update”变成“系统批处理”
传统 MonoBehaviour 里,常见写法是:
Monster.Update()
Monster.Update()
Monster.Update()
...每个怪物自己决定这一帧做什么。这个模式适合复杂对象,但如果每个对象做的事情都差不多,就会有重复调度和分散执行的问题。
ECS 则是:
MoveSystem.Update(): 处理所有移动数据
AttackSystem.Update(): 处理所有攻击数据
DeathSystem.Update(): 处理所有死亡数据执行顺序从“对象驱动”变成“系统驱动”:
flowchart TD
A[传统 OOP: 每个对象执行自己的 Update] --> B[对象 A 移动/攻击/死亡]
A --> C[对象 B 移动/攻击/死亡]
A --> D[对象 C 移动/攻击/死亡]
E[ECS: 每个系统批量处理一种逻辑] --> F[MoveSystem 处理所有移动]
E --> G[AttackSystem 处理所有攻击]
E --> H[DeathSystem 处理所有死亡]
flowchart TD
A[传统 OOP: 每个对象执行自己的 Update] --> B[对象 A 移动/攻击/死亡]
A --> C[对象 B 移动/攻击/死亡]
A --> D[对象 C 移动/攻击/死亡]
E[ECS: 每个系统批量处理一种逻辑] --> F[MoveSystem 处理所有移动]
E --> G[AttackSystem 处理所有攻击]
E --> H[DeathSystem 处理所有死亡]
flowchart TD
A[传统 OOP: 每个对象执行自己的 Update] --> B[对象 A 移动/攻击/死亡]
A --> C[对象 B 移动/攻击/死亡]
A --> D[对象 C 移动/攻击/死亡]
E[ECS: 每个系统批量处理一种逻辑] --> F[MoveSystem 处理所有移动]
E --> G[AttackSystem 处理所有攻击]
E --> H[DeathSystem 处理所有死亡]flowchart TD
A[传统 OOP: 每个对象执行自己的 Update] --> B[对象 A 移动/攻击/死亡]
A --> C[对象 B 移动/攻击/死亡]
A --> D[对象 C 移动/攻击/死亡]
E[ECS: 每个系统批量处理一种逻辑] --> F[MoveSystem 处理所有移动]
E --> G[AttackSystem 处理所有攻击]
E --> H[DeathSystem 处理所有死亡]
批处理的好处是:
- 代码访问的数据更稳定。
- 查询条件明确,容易跳过无关实体。
- 同类计算集中,方便优化。
- 系统之间的读写关系更容易分析。
3.1.3 ECS 更容易多线程
Unity Entities 文档说明,DOTS 架构会大量使用 C# Job System,并建议在系统代码中尽可能使用 jobs,但也提醒:如果系统处理的实体很少,调度 job 的开销可能超过多线程收益,需要用 Profiler 对比。
这句话很重要。它告诉我们两个事实:
- ECS 确实更适合多线程。
- 多线程也不是免费的,小任务不一定更快。
为什么 ECS 更适合多线程?因为 ECS 的系统通常能明确表达:
MoveSystem:
- 读取 MoveSpeed
- 读取 MoveTarget
- 写入 LocalTransform
DamageSystem:
- 读取 DamageEvent
- 写入 Health当系统知道自己读什么、写什么,调度器就更容易判断哪些任务可以并行,哪些任务必须等待。
OOP 也能多线程,但对象之间引用复杂、边界不清楚时,线程安全很难保证。一个对象方法里可能读 UI、改 Transform、发事件、查单例、写列表、调动画。你很难自动判断它能不能并行。
3.1.4 ECS 更适合 Burst 优化
Unity Burst 是一个面向高性能 C# 子集的编译器,常与 Job System 配合使用。Burst 喜欢的是:
- 值类型。
- 简单明确的数据结构。
- 少托管引用。
- 少虚调用和运行时多态。
- 可静态分析的代码。
这正好和 ECS 的数据导向写法契合。
比如移动系统本质上就是大量数值计算:
position = position + direction * speed * deltaTime这类代码用 struct、float3、IComponentData、ISystem 表达后,更容易被 Burst 优化成高效机器码。
3.1.5 ECS 减少 GC 压力
传统 OOP/MonoBehaviour 项目中,如果频繁创建对象、分配列表、产生临时闭包、LINQ、装箱,就可能触发 GC。GC 一旦在关键帧发生,就会造成卡顿。
ECS 并不会自动消灭所有内存问题,但它鼓励你使用:
- unmanaged component。
NativeArray、NativeList等 Collections。- 固定大小或可控生命周期的数据。
- 更少的托管对象分配。
这会让性能行为更可控,尤其适合需要稳定帧时间的模拟类项目。
3.2 ECS 也有自己的性能陷阱
不要把 ECS 理解成“用了就快”。用错 ECS,一样可能慢,甚至更难调。
3.2.1 Structural Change 可能很贵
Unity Entities 文档把创建/销毁实体、添加/移除组件、设置 shared component 等操作称为 structural change。原因是这些操作可能导致 Unity 重新组织 chunk 内存,甚至把实体移动到另一个 archetype。
比如:
给实体添加 Burning 组件
Entity 从 archetype A 移动到 archetype B如果你每帧对大量实体频繁添加/移除组件,就可能引发大量结构变化。
更麻烦的是,structural change 还可能形成 sync point。也就是主线程需要等待已经调度的 jobs 完成,导致多线程收益被打断。
实践建议:
- 高频状态切换优先考虑 enableable component、状态字段或 buffer,而不是每帧反复加删组件。
- 大量创建/销毁用 Entity Command Buffer 延迟执行。
- 把实体结构设计得稳定,避免运行中频繁改变 archetype。
3.2.2 Job 调度不是免费午餐
如果一个系统只处理几十个实体,直接在主线程 foreach 可能比调度并行 job 更快。Unity 官方 ECS workflow 文档也明确提醒:当系统工作量很小,parallel job 的调度开销可能超过多线程收益。
判断方式不要靠感觉,而要靠 Profiler:
实体少、逻辑轻:主线程 foreach 可能更好
实体多、逻辑重:Job + Burst 更可能有收益3.2.3 ECS 会牺牲一部分封装直觉
OOP 的舒服之处是:
monster.TakeDamage(10);
monster.PlayHitAnimation();
monster.TryDropLoot();你可以顺着对象读代码。
ECS 里这些逻辑可能散在不同系统:
DamageApplySystem
DeathCheckSystem
HitReactionSystem
LootDropSystem
AnimationEventBridgeSystem这对性能和批处理有利,但对初学者的代码追踪不友好。项目需要更明确的系统命名、更新顺序、调试工具和架构约定。
3.2.4 ECS 与 Unity 传统生态不是完全无缝
Unity 官方也强调 ECS 与 GameObject 生态兼容,但兼容不等于所有东西都应该 ECS 化。
这些内容通常仍然和传统 GameObject 生态关系很强:
- Animator。
- Timeline。
- Cinemachine。
- 大量第三方插件。
- 复杂 UI。
- 编辑器可视化工作流。
- 手工摆放的剧情对象和关卡机关。
如果你的系统主要依赖这些能力,强行 ECS 化可能得不偿失。
3.3 OOP 的价值不只是“简单”
很多 ECS 教程会把 OOP 讲得像落后方案,这是不公平的。
OOP 的核心价值是管理复杂性。比如一个 Boss:
- 有多阶段。
- 有剧情演出。
- 有动画状态机。
- 有技能前摇后摇。
- 有打断、霸体、镜头、音效、特效。
- 和 UI、任务、对白、场景机关交互。
这种对象的复杂性不在“数量多”,而在“行为特别”。用一个或多个 MonoBehaviour、状态机、行为树、ScriptableObject 配置来组织,往往比拆成几十个 ECS System 更清楚。
OOP 适合:
- 个体少但行为复杂。
- 强生命周期、强封装、强引用关系。
- 需要 Inspector 可视化调试。
- 需要和 Unity 传统组件密切配合。
- 玩法仍在快速原型阶段。
ECS 适合:
- 个体多且逻辑相似。
- 数据结构稳定。
- 更新频率高。
- CPU 热点明确。
- 可批处理、可并行。
- 对帧稳定性要求高。
3.4 一个实际游戏系统怎么拆
假设你在做一个幸存者类游戏,屏幕上会出现:
- 1 个玩家。
- 2000 个敌人。
- 5000 个投射物。
- 大量掉落物。
- 大量伤害数字。
- UI、音效、特效、动画。
纯 OOP 写法可能是:
PlayerController : MonoBehaviour
EnemyController : MonoBehaviour x 2000
Projectile : MonoBehaviour x 5000
Pickup : MonoBehaviour x 1000
DamageText : MonoBehaviour x 500早期开发没问题,但数量上来后,每帧大量 Update、碰撞查询、列表遍历和对象创建销毁可能开始吃性能。
更合理的混合拆法可以是:
| 模块 | 推荐方式 | 原因 |
|---|---|---|
| 玩家输入 | OOP / MonoBehaviour | 输入设备、镜头、动画、手感调试更直观 |
| 玩家表现 | OOP / GameObject | Animator、特效、音效、镜头绑定方便 |
| 敌人移动 | ECS | 大量敌人做相似移动,适合批处理 |
| 敌人索敌 | ECS | 大量实体查询距离、方向、目标 |
| 投射物飞行 | ECS | 数量大、逻辑统一、计算简单 |
| 伤害结算 | ECS | 大量事件批量处理 |
| Boss 行为 | OOP 或混合 | 个体少、行为复杂、演出多 |
| UI | OOP / UGUI / UI Toolkit | ECS 化收益低 |
| 音效播放 | OOP 桥接 | Unity 音频生态更偏 GameObject |
| 掉落物吸附 | ECS | 数量多时可批处理 |
一个常见结构是:
flowchart TD
A[MonoBehaviour 输入/相机/UI/表现] --> B[写入玩家意图或表现请求]
B --> C[ECS 模拟层]
C --> D[移动/索敌/投射物/伤害/死亡]
D --> E[输出事件: 死亡/受击/播放特效/刷新 UI]
E --> F[MonoBehaviour 表现层消费事件]
flowchart TD
A[MonoBehaviour 输入/相机/UI/表现] --> B[写入玩家意图或表现请求]
B --> C[ECS 模拟层]
C --> D[移动/索敌/投射物/伤害/死亡]
D --> E[输出事件: 死亡/受击/播放特效/刷新 UI]
E --> F[MonoBehaviour 表现层消费事件]
flowchart TD
A[MonoBehaviour 输入/相机/UI/表现] --> B[写入玩家意图或表现请求]
B --> C[ECS 模拟层]
C --> D[移动/索敌/投射物/伤害/死亡]
D --> E[输出事件: 死亡/受击/播放特效/刷新 UI]
E --> F[MonoBehaviour 表现层消费事件]flowchart TD
A[MonoBehaviour 输入/相机/UI/表现] --> B[写入玩家意图或表现请求]
B --> C[ECS 模拟层]
C --> D[移动/索敌/投射物/伤害/死亡]
D --> E[输出事件: 死亡/受击/播放特效/刷新 UI]
E --> F[MonoBehaviour 表现层消费事件]
关键思想是:
ECS 负责大量、稳定、可批量的模拟;OOP 负责复杂、少量、强表现和强编辑器工作流的部分。
3.5 ECS + OOP 更好,这是真的吗
大多数实际 Unity 项目里,这句话是成立的,但它不是绝对真理。
准确说法应该是:
对于复杂商业项目,ECS + OOP 混合通常比纯 ECS 或纯 OOP 更容易同时兼顾性能、开发效率、团队协作和 Unity 生态兼容。
为什么?
3.5.1 纯 OOP 容易在规模化模拟中撞墙
如果你有大量单位、子弹、状态、事件,纯 OOP 可能会遇到:
- 每个对象都有自己的生命周期回调。
- 对象引用关系复杂。
- 数据不连续,缓存不友好。
- 临时对象和事件分发产生 GC。
- 多线程改造困难。
- 热点系统难以整体优化。
不是说纯 OOP 不能优化,而是优化到后面,你往往会自己写出类似 ECS 的东西:数据数组、集中 Update、对象池、批量处理、任务调度。
3.5.2 纯 ECS 容易在工程体验中吃亏
纯 ECS 的问题通常不是“不能做”,而是“成本高”:
- 团队学习成本高。
- 代码追踪不如对象调用直观。
- 编辑器工作流需要 authoring/baking。
- 表现层、动画、UI、插件对接复杂。
- 小规模逻辑不一定有性能收益。
- 过度拆系统后,业务意图可能变得分散。
如果你把 UI、剧情、引导、商店、活动、设置页都强行 ECS 化,通常不是工程成熟,而是自找麻烦。
3.5.3 混合架构让两边各做擅长的事
混合架构的核心不是“我两种都用,所以先进”,而是明确边界:
OOP:
- 管人能理解的对象
- 管表现和编辑器工作流
- 管复杂个体
- 管系统入口和胶水层
ECS:
- 管机器要高效处理的数据
- 管大规模实体
- 管批量模拟
- 管性能热点一旦边界清楚,混合架构就很自然。
比如:
MonoBehaviour PlayerView
-> 读取输入
-> 播放动画
-> 把玩家位置或命令同步给 ECS
ECS EnemyMoveSystem
-> 批量更新敌人位置
-> 批量生成受击/死亡事件
MonoBehaviour EffectPresenter
-> 读取 ECS 输出事件
-> 播放 GameObject 特效和音效这样做不是妥协,而是承认游戏项目同时有两类问题:
- 给 CPU 高效处理的问题。
- 给人类高效开发的问题。
ECS 解决前者,OOP 解决后者。
四、实践示例
4.1 同一个移动逻辑,用 OOP 怎么写
这是一个典型 MonoBehaviour 移动脚本:
using UnityEngine;
public class MoveToTargetBehaviour : MonoBehaviour
{
[SerializeField] private float speed = 3f;
[SerializeField] private Transform target;
private void Update()
{
if (target == null)
{
return;
}
Vector3 direction = (target.position - transform.position).normalized;
transform.position += direction * speed * Time.deltaTime;
}
}这个写法适合:
- 玩家角色。
- 少量 NPC。
- 原型阶段。
- 需要频繁在 Inspector 调参数。
- 需要直接接动画、音效、粒子。
如果只有几十个对象,这样写完全合理。
4.2 同一个移动逻辑,用 ECS 怎么写
组件:
using Unity.Entities;
using Unity.Mathematics;
public struct MoveSpeed : IComponentData
{
public float Value;
}
public struct MoveTarget : IComponentData
{
public float3 Position;
}系统:
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
[BurstCompile]
public partial struct MoveToTargetSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
float deltaTime = SystemAPI.Time.DeltaTime;
foreach (var (transform, speed, target) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveSpeed>, RefRO<MoveTarget>>())
{
float3 current = transform.ValueRO.Position;
float3 direction = math.normalizesafe(target.ValueRO.Position - current);
transform.ValueRW.Position =
current + direction * speed.ValueRO.Value * deltaTime;
}
}
}这个写法适合:
- 成千上万敌人。
- 大量投射物。
- 简单重复的批量移动。
- 需要 Burst 编译。
- 后续可能改成 Job 并行。
但如果只有一个玩家角色,这样写就不一定划算。
4.3 从 OOP 迁移到 ECS 的安全步骤
不要一上来重写整个项目。比较稳的路线是:
flowchart TD
A[先用 OOP 做出可玩版本] --> B[用 Profiler 找 CPU 热点]
B --> C[判断热点是否大量同类数据]
C --> D{适合批量处理吗}
D -- 是 --> E[抽出纯数据组件]
E --> F[写 ECS System 批量处理]
F --> G[保留 OOP 表现层桥接]
D -- 否 --> H[继续用 OOP 优化结构]
flowchart TD
A[先用 OOP 做出可玩版本] --> B[用 Profiler 找 CPU 热点]
B --> C[判断热点是否大量同类数据]
C --> D{适合批量处理吗}
D -- 是 --> E[抽出纯数据组件]
E --> F[写 ECS System 批量处理]
F --> G[保留 OOP 表现层桥接]
D -- 否 --> H[继续用 OOP 优化结构]
flowchart TD
A[先用 OOP 做出可玩版本] --> B[用 Profiler 找 CPU 热点]
B --> C[判断热点是否大量同类数据]
C --> D{适合批量处理吗}
D -- 是 --> E[抽出纯数据组件]
E --> F[写 ECS System 批量处理]
F --> G[保留 OOP 表现层桥接]
D -- 否 --> H[继续用 OOP 优化结构]flowchart TD
A[先用 OOP 做出可玩版本] --> B[用 Profiler 找 CPU 热点]
B --> C[判断热点是否大量同类数据]
C --> D{适合批量处理吗}
D -- 是 --> E[抽出纯数据组件]
E --> F[写 ECS System 批量处理]
F --> G[保留 OOP 表现层桥接]
D -- 否 --> H[继续用 OOP 优化结构]
推荐迁移顺序:
- 先迁移最简单、最重复、最独立的逻辑,比如子弹移动。
- 再迁移实体数量大、表现要求低的逻辑,比如小怪移动。
- 然后迁移纯数值结算,比如伤害、Buff tick、冷却。
- 最后再考虑 AI、寻路、网络同步等复杂系统。
不要优先迁移:
- UI。
- 剧情流程。
- 商店和活动页面。
- 单个 Boss 的复杂演出。
- 还没稳定下来的玩法原型。
4.4 一个混合架构的代码边界示意
假设 ECS 负责敌人血量,MonoBehaviour 负责血条表现。
ECS 里可以产生一个简单事件:
using Unity.Entities;
public struct HealthChangedEvent : IBufferElementData
{
public Entity Target;
public float Current;
public float Max;
}表现层不直接参与伤害计算,而是消费结果:
using UnityEngine;
public class HealthBarPresenter : MonoBehaviour
{
public void SetValue(float current, float max)
{
float ratio = max <= 0f ? 0f : current / max;
// 更新 UI 进度条
}
}边界原则是:
ECS 不直接关心血条怎么画。
MonoBehaviour 不直接决定伤害怎么结算。这样可以避免两个世界互相污染。
4.5 选型速查表
| 问题 | 优先选择 |
|---|---|
| 这个逻辑是否有大量同类对象? | 是:考虑 ECS |
| 是否每帧处理大量重复数据? | 是:考虑 ECS |
| 是否需要 Burst / Job / 多线程? | 是:考虑 ECS |
| 是否主要是 UI、动画、音效、镜头? | 是:优先 OOP |
| 是否只有少数几个复杂对象? | 是:优先 OOP |
| 是否仍在快速试玩法? | 是:优先 OOP,后续按热点迁移 |
| 是否强依赖第三方 MonoBehaviour 插件? | 是:优先 OOP 或桥接 |
| 是否有稳定数据结构和明确性能瓶颈? | 是:适合 ECS |
五、常见误区与注意事项
5.1 误区一:ECS 一定比 OOP 快
不一定。
如果实体数量少、逻辑轻、系统拆得过碎,ECS 的查询、调度、桥接和心智成本可能比收益更大。
正确判断方式是:
先做出功能
再用 Profiler 找瓶颈
再判断瓶颈是否适合数据导向改造5.2 误区二:用了 ECS 就必须抛弃 GameObject
不需要。
Unity 官方也强调 ECS 与 GameObject 生态兼容。实际项目中很常见的做法是:
- GameObject 做 authoring。
- Baking 转成 Entity 数据。
- ECS 做运行时模拟。
- GameObject 或 Entities Graphics 做表现。
- MonoBehaviour 做 UI、音频、特效、外部插件桥接。
混合不是不纯,而是务实。
5.3 误区三:ECS 就是把 MonoBehaviour 拆成三个文件
不是。
如果只是把:
Monster.hp
Monster.Move()
Monster.Attack()机械拆成:
HealthComponent
MoveComponent
AttackSystem但数据仍然到处引用、系统仍然逐对象查找、频繁结构变化,那么不一定有性能收益。
ECS 的重点是数据布局、访问模式、批处理和系统边界。
5.4 误区四:所有逻辑都应该纯数据
组件最好保持纯数据,但项目代码不可能只有数据。表现层、编辑器工具、资源管理、流程控制、调试工具都需要更丰富的对象模型。
成熟架构不是极端纯粹,而是边界清楚:
模拟层:尽量数据导向
表现层:保留对象导向
工具层:以开发效率为先5.5 误区五:ECS 可以替代所有设计模式
ECS 是一种架构模式,不是所有问题的答案。
你仍然可能需要:
- 状态机。
- 行为树。
- 事件队列。
- 命令模式。
- 数据表配置。
- 对象池。
- 分层架构。
- MVVM 或 MVC 风格 UI 架构。
ECS 主要解决“运行时大量数据如何高效组织和处理”的问题,不负责替你设计所有业务规则。
5.6 误区六:一开始就全项目 ECS 化
除非项目目标非常明确,比如从第一天就要做大规模模拟、确定性服务器、海量单位,否则不建议一开始就全项目 ECS 化。
更稳的方式是:
- 用传统 Unity 工作流做出可玩性。
- 通过数据和 Profiler 找到真实瓶颈。
- 把稳定、重复、数量大的系统迁到 ECS。
- 保留 MonoBehaviour 作为表现、输入、UI、资源和工具层。
这条路线通常比“先设计一个完美 ECS 架构”更可靠。
六、与相关技术的对比
6.1 ECS、OOP、DOD、DOTS 的关系
| 概念 | 是什么 | 和 Unity 的关系 |
|---|---|---|
| OOP | 面向对象编程范式 | Unity 传统 GameObject + MonoBehaviour 工作流常用 |
| DOD | 数据导向设计思想 | 关注数据布局、访问模式、缓存和批处理 |
| ECS | Entity-Component-System 架构模式 | Unity Entities 是一种 ECS 实现 |
| DOTS | Unity Data-Oriented Technology Stack | 包含 Entities、Job System、Burst、Collections 等相关技术 |
| Burst | 高性能 C# 编译器 | 常用于编译 job 和数据导向代码 |
| Job System | Unity 多线程任务系统 | ECS 系统常用它调度并行任务 |
简单说:
DOD 是思想
ECS 是架构
Entities 是 Unity 的 ECS 实现
DOTS 是 Unity 的数据导向技术栈
Burst 和 Job System 是性能工具6.2 ECS 与传统 Unity Component 的区别
| 维度 | GameObject Component | ECS Component |
|---|---|---|
| 典型形态 | MonoBehaviour class |
IComponentData struct |
| 是否包含行为 | 通常包含 | 通常只放数据 |
| 是否可直接挂 Inspector | 是 | 通常通过 authoring/baking |
| 运行时更新 | Unity 生命周期回调 | System 查询并处理 |
| 数据布局 | 围绕对象和组件实例 | 围绕 archetype 和 chunk |
| 适用重点 | 编辑器工作流和对象表达 | 批量数据处理和性能 |
6.3 什么时候用什么
| 场景 | 推荐 |
|---|---|
| 玩家控制器 | OOP |
| UI 弹窗 | OOP |
| 单个 Boss 复杂行为 | OOP 或 OOP 主导 |
| 大量小怪移动 | ECS |
| 大量子弹飞行 | ECS |
| 大量 Buff tick | ECS |
| 剧情演出 | OOP |
| 大量资源点产出模拟 | ECS |
| 编辑器工具 | OOP |
| 服务器权威战斗模拟 | ECS 倾向更强 |
| 原型验证 | OOP 起步,热点再迁移 |
七、实际项目落地建议
7.1 判断一个系统是否适合 ECS 的四个问题
写代码前先问四个问题:
- 这个系统是不是会有很多实体?
- 这些实体是不是拥有相似数据?
- 这些实体是不是每帧或高频执行相似逻辑?
- 这个系统是不是已经或很可能成为性能热点?
如果四个答案大多是“是”,ECS 值得考虑。
如果答案是:
对象很少
行为很特殊
表现很复杂
还在频繁改玩法那就先用 OOP。
7.2 推荐的 Unity 项目分层
一个比较稳的混合分层可以是:
Presentation 层:
GameObject / MonoBehaviour / Animator / UI / Audio / VFX
Simulation 层:
ECS Components / Systems / Jobs / Burst
Bridge 层:
Authoring / Baking / Event Bridge / View Binding
Config 层:
ScriptableObject / JSON / 表格 / BlobAsset层之间的依赖尽量保持单向:
配置 -> 模拟 -> 事件 -> 表现不要让 ECS System 到处直接操作 MonoBehaviour,也不要让 MonoBehaviour 随意修改 ECS 内部状态。最好通过明确的命令、事件、同步组件或桥接层传递数据。
7.3 初学 ECS 的练习顺序
建议从低风险系统开始:
- 子弹移动。
- 掉落物吸附。
- 简单小怪移动。
- 周期性伤害。
- Buff 计时。
- 目标选择。
- 批量生成和销毁。
- 简单群体 AI。
不建议一开始练:
- 复杂角色动画。
- 完整战斗框架。
- 技能编辑器。
- UI 框架。
- 剧情系统。
- 大型网络同步。
先找最像“表格批处理”的系统下手。
7.4 性能验证方法
不要用“感觉 ECS 更快”作为结论。至少做这些验证:
- Unity Profiler 看 CPU main thread、jobs、GC alloc。
- 对比 MonoBehaviour 版本与 ECS 版本的实体数量上限。
- 分别测试编辑器和 Player,编辑器数据可能误导。
- 测试低端目标设备,而不是只看开发机。
- 对比主线程 foreach、单 job、parallel job 的成本。
- 检查 structural change 和 sync point。
- 看帧时间稳定性,而不是只看平均 FPS。
性能优化的目标不是跑出一个漂亮峰值,而是让真实游戏场景稳定。
八、总结
ECS 与 OOP 不是谁淘汰谁的关系。
OOP 的优势是让人更容易理解复杂对象:玩家、Boss、UI、镜头、剧情、表现层都很适合对象思维。它贴合 Unity 传统工作流,开发速度快,调试直观,适合大多数日常玩法代码。
ECS 的优势是让机器更容易处理大量数据:小怪、子弹、Buff、状态、模拟、批量结算都很适合数据导向。它通过更好的数据布局、批处理、多线程和 Burst 优化,让 CPU 热点更容易被压下去。
所以实际项目中常说 ECS + OOP 更好,不是因为混合本身神奇,而是因为游戏项目天然同时有两类需求:
人要高效开发复杂体验。
机器要高效处理大量数据。OOP 负责前者,ECS 负责后者。能把边界划清楚,比争论“哪个范式更先进”重要得多。
延伸阅读
- Unity ECS for Unity:https://unity.com/ecs
- Unity DOTS:https://unity.com/dots
- Unity Entities package:https://docs.unity3d.com/Packages/com.unity.entities%401.4/manual/index.html
- Unity Entity component system introduction:https://docs.unity3d.com/Packages/com.unity.entities%401.4/manual/concepts-ecs.html
- Unity Entity concepts:https://docs.unity3d.com/Packages/com.unity.entities%401.4/manual/concepts-entities.html
- Unity Component concepts:https://docs.unity3d.com/Packages/com.unity.entities%401.4/manual/concepts-components.html
- Unity System concepts:https://docs.unity3d.com/Packages/com.unity.entities%401.4/manual/concepts-systems.html
- Unity Archetype concepts:https://docs.unity3d.com/Packages/com.unity.entities%401.4/manual/concepts-archetypes.html
- Unity Structural changes concepts:https://docs.unity3d.com/Packages/com.unity.entities%401.4/manual/concepts-structural-changes.html
- Unity Job system in Entities:https://docs.unity3d.com/Packages/com.unity.entities%401.4/manual/systems-scheduling-jobs.html
- Unity ECS packages:https://docs.unity3d.com/Packages/com.unity.entities%401.4/manual/ecs-packages.html
- Unity Burst manual:https://docs.unity3d.com/Packages/com.unity.burst%401.8/index.html
- Unity MonoBehaviour manual:https://docs.unity3d.com/6000.4/Documentation/Manual/class-MonoBehaviour.html