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

目录

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

摘要:本文面向已经接触过 Unity GameObjectMonoBehaviour,但还不清楚 ECS 到底解决什么问题的开发者。文章会先用人话解释 ECS 与 OOP 的根本区别,再拆开说明为什么 ECS 在大量同类对象、批量模拟和 CPU 热点场景下通常更快。最后会给出实际项目中的选型方式:哪些系统继续用 OOP,哪些系统适合迁到 ECS,以及为什么很多 Unity 项目采用 ECS + OOP 的混合架构,而不是二选一。

一、背景与动机

1.1 先给结论:ECS 快,不是因为名字先进

Unity 游戏开发里说的 ECS,通常指 Entity Component System,中文常译为“实体-组件-系统”。在 Unity 当前官方体系里,它主要落在 Entities packageDOTS 生态中。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 : MonoBehaviour

Unity 的 MonoBehaviour 官方文档也说明,MonoBehaviour 是可以附加到 GameObject 上的脚本框架,并提供 StartUpdate 等事件函数。这个模式非常直观,也非常适合编辑器工作流。

但是传统 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 逻辑,批量处理拥有某些组件的实体 ISystemSystemBase

一个 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;
        }
    }
}

注意这个系统没有问“你是什么类”。它只问:

哪些实体同时拥有 LocalTransformMoveSpeedMoveTarget?把它们拿出来,批量移动。

这就是 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 处理所有死亡]

批处理的好处是:

  • 代码访问的数据更稳定。
  • 查询条件明确,容易跳过无关实体。
  • 同类计算集中,方便优化。
  • 系统之间的读写关系更容易分析。

3.1.3 ECS 更容易多线程

Unity Entities 文档说明,DOTS 架构会大量使用 C# Job System,并建议在系统代码中尽可能使用 jobs,但也提醒:如果系统处理的实体很少,调度 job 的开销可能超过多线程收益,需要用 Profiler 对比。

这句话很重要。它告诉我们两个事实:

  1. ECS 确实更适合多线程。
  2. 多线程也不是免费的,小任务不一定更快。

为什么 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

这类代码用 structfloat3IComponentDataISystem 表达后,更容易被 Burst 优化成高效机器码。

3.1.5 ECS 减少 GC 压力

传统 OOP/MonoBehaviour 项目中,如果频繁创建对象、分配列表、产生临时闭包、LINQ、装箱,就可能触发 GC。GC 一旦在关键帧发生,就会造成卡顿。

ECS 并不会自动消灭所有内存问题,但它鼓励你使用:

  • unmanaged component。
  • NativeArrayNativeList 等 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 表现层消费事件]

关键思想是:

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 优化结构]

推荐迁移顺序:

  1. 先迁移最简单、最重复、最独立的逻辑,比如子弹移动。
  2. 再迁移实体数量大、表现要求低的逻辑,比如小怪移动。
  3. 然后迁移纯数值结算,比如伤害、Buff tick、冷却。
  4. 最后再考虑 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 化。

更稳的方式是:

  1. 用传统 Unity 工作流做出可玩性。
  2. 通过数据和 Profiler 找到真实瓶颈。
  3. 把稳定、重复、数量大的系统迁到 ECS。
  4. 保留 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 的四个问题

写代码前先问四个问题:

  1. 这个系统是不是会有很多实体?
  2. 这些实体是不是拥有相似数据?
  3. 这些实体是不是每帧或高频执行相似逻辑?
  4. 这个系统是不是已经或很可能成为性能热点?

如果四个答案大多是“是”,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 的练习顺序

建议从低风险系统开始:

  1. 子弹移动。
  2. 掉落物吸附。
  3. 简单小怪移动。
  4. 周期性伤害。
  5. Buff 计时。
  6. 目标选择。
  7. 批量生成和销毁。
  8. 简单群体 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

目录