上班摸鱼下班游戏头像

Unity ECS 架构设计

Unity ECS 架构设计完全指南:从 OOP 到 DOTS(原理、流程图、真实代码、万级单位实战)

当一个游戏里同时出现 1 万个单位,你的 MonoBehaviour.Update 会直接把帧率打成个位数。这不是代码写得不好,而是架构选错了——传统的"对象 + 组件"模式,在数据量面前天生低效。ECS(Entity-Component-System,实体-组件-系统) 正是为了解决这个问题而生的架构:它把数据从对象里"拎出来"按类型排成连续数组,让 CPU 缓存命中率飙升,让多线程并行变得天然可行。本文以 Unity 官方 DOTS(Entities + Jobs + Burst) 为例,从 架构原理 → 流程图 → 核心 API → 真实可运行代码 → 万级单位实战 → 设计原则 → 从 OOP 迁移 逐层拆解,最后还会手写一个不依赖 DOTS 的纯 C# ECS,让你彻底吃透这套架构。无论你是 Unity 开发者还是做引擎/服务器架构,这篇文章都能让你从"听说过 ECS"到"能设计 ECS"。


目录


一、为什么需要 ECS?OOP 的三个天花板

1.1 天花板一:内存不连续,CPU 缓存疯狂 Miss

先看 Unity 里最经典的 OOP 写法:

// 传统写法:每个敌人是一个 GameObject,身上挂着几个 MonoBehaviours
public class Enemy : MonoBehaviour
{
    public float speed;
    public Vector3 position;
    public int hp;

    void Update()
    {
        position += Vector3.right * speed * Time.deltaTime;
        transform.position = position;
        // ...更多逻辑
    }
}

问题在哪? 当你遍历 10000 个 Enemy 时,它们散落在托管堆的各个角落:

托管堆内存(示意):
[Enemy A 对象][其他对象][Enemy B 对象][字符串][更多对象][Enemy C 对象]...
        ↑                    ↑                          ↑
     相距几百字节        中间夹着垃圾              相距更远

CPU 每次读 Enemy.speed,都要把一整条 Cache Line(通常 64 字节) 搬进 L1/L2 缓存。但 89% 的缓存内容是你根本不需要的字段(比如 hp、名字、引用),有效载荷只有 4 字节——缓存利用率约 1/16。

遍历 1 万个对象 = 1 万次随机内存访问 + 1 万次 Cache Miss。这就是为什么你的 1 万单位游戏卡成 PPT。

1.2 天花板二:继承与组件膨胀,逻辑互相纠缠

传统 OOP 游戏架构常见三种写法,各有各的坑:

写法问题
继承(Enemy : Character : MonoBehaviour)继承链一经固定,加功能就要改基类,"组合"能力极差
万能 God Class(一个类塞所有逻辑)文件 5000 行,改一处崩三处,无法并行
MonoBehaviour 堆叠每个组件独立 Update,Unity 要逐个调用虚方法,调用开销大;组件之间靠 GetComponent 相互耦合

1.3 天花板三:单线程,多核全闲

MonoBehaviour.Update 全部跑在主线程。你有 8 核 CPU,游戏只用 1 核。想要并行?你得手动把逻辑拆成 Job,但传统的"数据散落在对象里"根本无法安全并行(数据依赖、竞态)。

1.4 ECS 给出的答案

问题ECS 的解法
内存不连续组件按类型连续存储:所有 MoveSpeed 排成一个数组,缓存命中率接近 100%
继承纠缠组件组合 + 系统分离:要什么功能就挂什么组件,逻辑全在系统里
单线程数据与逻辑分离后,天然可并行:不同 Chunk 之间无数据依赖,Job 直接并行

一句话概括 ECS:把"数据"和"行为"彻底拆开——数据以"组件"为单位按类型连续存放,行为以"系统"为单位批量处理符合条件的实体。数据驱动,而不是对象驱动。


二、ECS 核心概念与架构流程图

2.1 四要素:Entity / Component / System / World

概念本质在 Unity DOTS 中的实现
Entity(实体)只是一个 ID(32 位整数),不含任何数据Entity struct(含 Index + Version)
Component(组件)纯数据,无逻辑。按类型组织IComponentData struct
System(系统)纯逻辑,无状态。批量处理拥有的组件组合SystemBase / ISystem
World(世界)所有实体+组件的容器,一个游戏可以有多个 WorldWorld(World.DefaultGameObjectInjectionWorld)

关键点:Entity 本身不是对象,它只是一个"句柄/索引"。数据存在组件数组里,Entity 只是"第几行"的编号。这就是 ECS 和 OOP 最本质的区别。

2.2 整体架构流程图(Mermaid)

World(世界容器)

Systems(纯逻辑,按顺序调度)

Entity(只是 ID)

组件存储(按类型连续排列)

ID=0 索引

ID=0 索引

ID=0 索引

ID=1

ID=1

ID=2

ID=2

批量读写

批量读写

批量读写

批量读

System A:移动逻辑
Query: MoveSpeed + Transform

Entity #0

MoveSpeed[] 数组
[0]=1.0 [1]=2.5 [2]=0.8 ...

Health[] 数组
[0]=100 [1]=50 [2]=80 ...

LocalTransform[] 数组
[0]=posA [1]=posB [2]=posC ...

Entity #1

Entity #2

System B:伤害逻辑
Query: Health + Damage

System C:渲染同步
Query: LocalToWorld

这张图要记住三件事:

  1. Entity 是索引,指向三条数组中同一"行"的数据;
  2. 每个组件按类型排成一条连续数组(这是性能的关键);
  3. System 只认"组件组合",不认"Entity 是谁"——它批量扫一遍所有符合条件的实体。

2.3 同一架构的 ASCII 图(不依赖 Mermaid 渲染)

┌──────────────────────────── World ─────────────────────────────┐
│                                                                │
│  Entity #0 ─┬──▶ MoveSpeed[0] = 1.0                            │
│             ├──▶ Health[0]     = 100                           │
│             └──▶ Transform[0]  = (0,0,0)                       │
│                                                                │
│  Entity #1 ─┬──▶ MoveSpeed[1] = 2.5                            │
│             ├──▶ Health[1]     = 50                            │
│             └──▶ Transform[1]  = (5,0,0)                       │
│                                                                │
│  Entity #2 ─┬──▶ MoveSpeed[2] = 0.8                            │
│             └──▶ Transform[2]  = (9,0,0)  (没有 Health!)       │
│                                                                │
│  ┌─────────────────────────────────────────────────────┐        │
│  │ System A(移动)  Query:MoveSpeed + Transform      │        │
│  │   → 批量遍历,把所有实体位置 += speed * dt          │        │
│  └─────────────────────────────────────────────────────┘        │
│  ┌─────────────────────────────────────────────────────┐        │
│  │ System B(伤害)  Query:Health + DamageEvent       │        │
│  │   → 只处理"同时有这两个组件"的实体(Entity #2 跳过)│        │
│  └─────────────────────────────────────────────────────┘        │
└────────────────────────────────────────────────────────────────┘

注意 Entity #2 没有 Health:ECS 允许不同实体拥有不同组件组合,System 只处理"拥有指定组件集合"的实体子集。这就是 ECS 的灵活组合能力——不需要继承,实体是什么,取决于它挂了什么组件。

2.4 ECS 每帧的运行流程

③ System 调度(每帧固定顺序)

InputSystem
读输入 → 写 InputComponent

PhysicsSystem
物理模拟 → 写 Transform

GameplaySystem
移动/战斗/状态机 → 写组件

TransformSystem
LocalTransform → LocalToWorld

RenderSystem
读 LocalToWorld → 提交渲染数据

① 输入/事件
玩家点击、网络消息

② 生成实体
CreateEntity + 设置组件

④ 渲染输出
Entities Graphics 批量绘制

⑤ 下一帧

流程要点:

  • 系统顺序是确定的(SystemGroup 上的 UpdateBefore/UpdateAfter 或 [UpdateInGroup] 特性控制);
  • 每个系统 只读写自己关心的组件,互不干扰;
  • LocalTransform(局部变换)→ LocalToWorld(世界矩阵)的转换由 Unity 提供的 TransformSystemGroup 统一处理,业务系统只改 LocalTransform 即可。

2.5 ECS 里没有"对象"了,那"我的角色"是什么?

很多初学者的困惑:“我的玩家角色去哪了?”

答案是:"角色"不是对象,而是一个 Entity + 一串组件 + 若干 Tag。

// 玩家 = 一个 Entity,挂了这些组件:
Entity player = em.CreateEntity(
    typeof(PlayerTag),        // 标记:这是玩家(空组件,只做标签)
    typeof(MoveSpeed),        // 数据:移动速度
    typeof(Health),           // 数据:生命值
    typeof(LocalTransform),   // 数据:位置
    typeof(RenderMesh)        // 数据:渲染网格
);

"玩家"这个概念存在于系统的筛选条件里:Query<PlayerTag, MoveSpeed> 只处理带 PlayerTag 的实体。


三、OOP 与 ECS 的内存布局对比

3.1 一图看清差异

【OOP 布局】10000 个 Enemy 对象散落堆内存
┌──────────┐   ┌──────────┐   ┌──────────┐
│ Enemy #0 │   │ 垃圾对象 │   │ Enemy #1 │  ...
│ speed    │   │          │   │ speed    │
│ hp       │   │          │   │ hp       │
│ 名字字符串│   │          │   │ 名字字符串│
│ 引用...  │   │          │   │ 引用...  │
└──────────┘   └──────────┘   └──────────┘
   ↑ Cache Miss 极高(每次读只用到 4 字节,却搬 64 字节 Cache Line)

【ECS 布局】组件按类型连续排列
MoveSpeed[]:    [1.0][2.5][0.8][3.1][1.9]...   ← 一个连续数组,10000 个 float
Health[]:       [100][50][80][120]...          ← 另一个连续数组
LocalTransform[]: [pos0][pos1][pos2][pos3]...  ← 另一个连续数组
   ↑ 遍历时顺序读,Cache Line 满载,命中率接近 100%

3.2 缓存利用率量化对比

OOP(散落对象)ECS(连续数组)
每次遍历的 Cache Miss高(每对象一次)极低(每 16 个 float 才换一次 Cache Line)
单次遍历有效载荷约 4/64 = 6%接近 100%
10000 实体遍历耗时毫秒级,随数量线性恶化微秒级,几乎线性但常数极小
能否 Job 并行难(数据依赖 + 引用类型)易(Chunk 天然可分割)

上表是原理层面的量级对比。实际数字取决于你的设备、组件大小、是否开 Burst。真正的做法是:用同一台设备跑"GameObject 版"和"ECS 版"做 A/B 对比(第九章给完整代码)。

3.3 OOP 与 ECS 的对照表

维度OOP(MonoBehaviour)ECS(DOTS)
数据单位对象(引用类型,堆分配)组件(值类型 struct,连续数组)
行为单位方法(挂在对象上)系统(批量处理)
扩展方式继承 / 加组件加组件 / 加系统
内存管理GC 压力大几乎无 GC(值类型)
并行能力弱强(Job System + Burst)
上手难度低(Unity 默认)高(需要理解 Archetype/Chunk)
适合规模数百~数千对象数千~数百万实体
调试体验好(Inspector 可视化)一般(需 Entities Debugger)

重要结论:ECS 不是要取代 OOP。UI、存档、编辑器工具、游戏流程控制,用 OOP 更舒服;只有"海量同构实体 + 每帧高频计算"的场景(单位、子弹、粒子、体素、弹幕、模拟)才值得上 ECS。第十二章会详细讲边界。


四、Unity DOTS 环境搭建与组件定义

4.1 DOTS 是什么?四块拼图

DOTS(Data-Oriented Technology Stack,面向数据技术栈) 不是一个包,而是四个技术的组合:

DOTS 面向数据技术栈

Entities
(ECS 运行时)
实体/组件/系统/World

Jobs System
(多线程任务调度)
把计算拆成 Job 分发到多核

Burst Compiler
(LLVM 编译器)
C# 代码编译成接近 C++ 的机器码

Collections
(专用集合)
NativeArray / NativeHashMap
无 GC、可跨线程

Entities Graphics
(原 Hybrid Renderer)
批量渲染 ECS 实体

技术职责少了它会怎样
Entities提供 ECS 数据结构与系统调度没有 ECS 本体
Jobs System把工作拆成可并行的 Job只能单线程,性能打折
Burst把 C# 编译成 SIMD 优化的机器码性能打 3~10 折
Collections提供 NativeArray 等跨线程容器Job 里不能用普通 List

关键点:ECS 不等于 DOTS。ECS 是架构思想,DOTS 是 Unity 的实现方案;Jobs 和 Burst 是"给 ECS 加速的引擎"。理论上你可以手写 ECS 不用 Jobs/Burst(第十一章就带你写一个),但那只是"学了架构",跑不出 DOTS 的性能。

4.2 环境搭建(Unity 2022.3 LTS 及以上)

版本说明:本文所有代码基于 Entities 1.x(Unity 2022.3 LTS / Unity 6)。如果你用的是 Entities 0.x(旧版),API 差异很大(Translation vs LocalTransform、ComponentSystem vs SystemBase),请按 1.x 版本对齐。

步骤 1:打开 Package Manager,添加以下包

包名用途必需
com.unity.entitiesECS 核心✅
com.unity.entities.graphicsECS 批量渲染需要渲染时
com.unity.rendering.hybrid旧版渲染(1.x 已并入 graphics)视版本
com.unity.physicsECS 物理需要物理时
com.unity.collectionsNativeArray 等(通常随 entities 自动装)自动

添加方式:

  1. Window → Package Manager;
  2. 点左上角 + → Add package by name;
  3. 输入 com.unity.entities,等待安装;
  4. 装完后 Project 里会出现 Unity.Entities 程序集。

步骤 2:项目设置(关键,不做会报错)

Edit → Project Settings → Player → Other Settings:

设置项值原因
Scripting BackendIL2CPPBurst 只支持 IL2CPP(编辑器里也能跑,但真机必须)
Api Compatibility Level.NET Standard 2.1 / .NET Framework建议 .NET Standard
Allow ‘unsafe’ Code视需求一般不需要

步骤 3:Entities Graphics(渲染)额外设置

如果用 URP:

  1. 安装 com.unity.render-pipelines.universal;
  2. 在 URP Asset 里,Rendering → Entities Graphics 会被自动启用;
  3. 材质的 Shader 必须用 URP 兼容 Shader(Entities Graphics 支持 Standard/URP Lit/Unlit)。

步骤 4:验证环境(写第一个 System 看是否编译通过)

using Unity.Entities;

// 一个空系统,只要这个类能编译通过,Entities 就装好了
public partial class HelloWorldSystem : SystemBase
{
    protected override void OnUpdate()
    {
        // 暂时什么都不做
    }
}

4.3 组件(Component)定义:只有数据,没有逻辑

ECS 的组件必须是 struct,实现 IComponentData,里面不能有方法、不能有引用类型字段(除少数特例)。

using Unity.Entities;
using Unity.Mathematics;

// ============ ① 纯数据组件 ============
public struct MoveSpeed : IComponentData
{
    public float Value;        // 每秒移动速度
}

public struct Health : IComponentData
{
    public float Current;
    public float Max;
}

// ============ ② 空组件 = 标签(Tag) ============
// 用于"筛选",不存数据,只用来标记实体身份/状态
public struct PlayerTag : IComponentData { }
public struct EnemyTag : IComponentData { }
public struct DeadTag : IComponentData { }   // 挂上这个 = 已死亡

// ============ ③ 带数学类型的数据 ============
// Unity.Mathematics 是 Burst 友好的数学库(float3/int2/quaternion)
public struct Velocity : IComponentData
{
    public float3 Value;       // 用 float3 而不是 Vector3(Burst 性能更好)
}

为什么必须用 struct?

原因说明
连续存储值类型能排成连续数组(MoveSpeed[]),这是 ECS 性能的根本
无 GCstruct 不产生托管堆分配,运行时不触发 GC
Burst 友好Burst 只能编译值类型、数学类型、NativeArray

命名规范(重要,能省你无数调试时间):

public struct MoveSpeed : IComponentData { }      // 名词,描述"是什么"
public struct DamageEvent : IComponentData { }    // 事件类组件加 Event 后缀
public struct PlayerTag : IComponentData { }       // 标签加 Tag 后缀

常见错误:在 IComponentData 里放 List<GameObject>、string、class —— 编译能过,但会退化成托管存储(无法用 Burst、无法 Job),性能全无。需要复杂数据时用 DynamicBuffer(第七章)或 BlobAsset。

4.4 创建实体(Entity)的五种方式

方式一:EntityManager.CreateEntity(最常用,代码动态创建)
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

public class SpawnerAuthoring : MonoBehaviour
{
    public GameObject prefab;      // 可以挂 Baker 转成 ECS 预制体
    public int count = 1000;
    public float speed = 2f;

    void Start()
    {
        var world = World.DefaultGameObjectInjectionWorld;
        var em = world.EntityManager;

        for (int i = 0; i < count; i++)
        {
            var entity = em.CreateEntity();                      // ① 创建一个空实体

            em.AddComponent<MoveSpeed>(entity);                  // ② 添加组件
            em.SetComponentData(entity, new MoveSpeed { Value = speed });

            em.AddComponent<LocalTransform>(entity);             // ③ 加位置
            em.SetComponentData(entity, LocalTransform.FromPosition(
                new float3(i % 100 * 0.5f, 0, i / 100 * 0.5f)));

            em.AddComponent<EnemyTag>(entity);                   // ④ 加标签
        }
    }
}

这种方式的问题:每次 CreateEntity + AddComponent 都是多次结构变更,1000 次循环会很慢。大批量创建用方式二。

方式二:CreateArchetype + CreateEntity(archetype, count)(批量创建,最快)
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

public class BulkSpawner : MonoBehaviour
{
    public int count = 10000;
    public float speed = 3f;

    void Start()
    {
        var em = World.DefaultGameObjectInjectionWorld.EntityManager;

        // ① 定义"原型":一组固定的组件组合
        var archetype = em.CreateArchetype(
            typeof(MoveSpeed),
            typeof(LocalTransform),
            typeof(EnemyTag),
            typeof(Unity.Rendering.RenderMesh)   // 要渲染才加
        );

        // ② 一次创建 count 个同构实体(注意:性能比循环 CreateEntity 快数倍)
        var entities = em.CreateEntity(archetype, count, Unity.Collections.Allocator.Temp);

        // ③ 批量设置组件数据(用 NativeArray)
        var speeds = new Unity.Collections.NativeArray<MoveSpeed>(count, Unity.Collections.Allocator.Temp);
        var transforms = new Unity.Collections.NativeArray<LocalTransform>(count, Unity.Collections.Allocator.Temp);

        for (int i = 0; i < count; i++)
        {
            speeds[i] = new MoveSpeed { Value = speed };
            transforms[i] = LocalTransform.FromPosition(
                new float3(i % 200 * 0.6f, 0, i / 200 * 0.6f));
        }

        em.CopyComponentDataArray(speeds, entities);       // 一次性批量写入
        em.CopyComponentDataArray(transforms, entities);

        speeds.Dispose();
        transforms.Dispose();
        entities.Dispose();
    }
}

Allocator.Temp 用完必须 Dispose。这是 NativeArray 的铁律,忘了就是内存泄漏(第七章详述)。

方式三:SubScene + Authoring(美术/策划最爱的编辑器工作流)
  1. 场景里放一个 SubScene(GameObject → Entities → SubScene);
  2. 在 SubScene 里像平时一样摆 GameObject、挂 MonoBehaviour 的"Authoring"脚本;
  3. 给 Authoring 脚本加 Baker,把 GameObject 数据转成 ECS 组件:
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;

// 1. 普通 MonoBehaviour(Authoring,只在编辑器和转换时存在)
public class MoveSpeedAuthoring : MonoBehaviour
{
    public float value = 2f;

    // 2. Baker:把 Authoring 数据转成 ECS 组件
    class Baker : Baker<MoveSpeedAuthoring>
    {
        public override void Bake(MoveSpeedAuthoring authoring)
        {
            var entity = GetEntity(TransformUsageFlags.Dynamic);   // 拿到转换后的实体
            AddComponent(entity, new MoveSpeed { Value = authoring.value });
        }
    }
}

SubScene 的好处:美术可以直接在编辑器里拖拽摆场景,打包时自动"烘焙(Bake)"成 ECS 实体,不用写代码生成。这是正式项目的主流做法。

方式四:从预制体实例化(Prefab → Entity)
public class PrefabSpawner : MonoBehaviour
{
    public GameObject enemyPrefab;      // 场景里一个普通预制体

    void Start()
    {
        var em = World.DefaultGameObjectInjectionWorld.EntityManager;
        // 直接实例化 ECS 预制体(需要预制体挂在 SubScene 或转成 ECS 预制体)
        var prefabEntity = em.CreateEntity();
        // 更推荐的做法:给预制体挂 Authoring + Baker,用 GetEntity 拿
    }
}

1.x 里"从 GameObject 预制体实例化 ECS 实体"的推荐路径是 SubScene + Baker,或 EntityManager.Instantiate(prefabEntity)。直接 Instantiate(GameObject) 会创建普通 GameObject,不是 ECS 实体。

方式五:Instantiate 已有实体(复制模板)
// 先创建/获取一个模板实体
Entity template = em.CreateEntity(typeof(MoveSpeed), typeof(LocalTransform), typeof(EnemyTag));

// 克隆 100 份(比从零创建快,因为结构已知)
var clones = em.Instantiate(template, 100, Allocator.Temp);

4.5 五种创建方式的选择表

方式适用性能
CreateEntity() + 逐个 AddComponent少量、结构不固定慢
CreateArchetype + CreateEntity(archetype, count)大批量同构实体(最优)最快
SubScene + Baker美术摆场景、正式项目快(编辑期烘焙)
从 ECS 预制体 Instantiate运行时动态刷怪快
Instantiate(template, count)克隆同构模板快

五、System 与 Query:ECS 的"大脑"

5.1 System 是什么?

System 是"纯逻辑":它不持有任何实体数据,只是在每帧被调度时,遍历所有符合条件的实体,批量读写它们的组件。

using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

// SystemBase 版:写法直观,适合交互调试
public partial class MoveSystem : SystemBase
{
    protected override void OnUpdate()
    {
        float deltaTime = SystemAPI.Time.DeltaTime;   // 本帧时间

        // Query:找出所有"同时有 LocalTransform + MoveSpeed"的实体
        foreach (var (transform, speed) in
                 SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveSpeed>>())
        {
            // RefRW = 可读写;RefRO = 只读
            transform.ValueRW.Position += new float3(speed.ValueRO.Value * deltaTime, 0, 0);
        }
    }
}

逐行解释:

代码含义
SystemAPI.Time.DeltaTime本帧时间(Burst 友好的写法)
SystemAPI.Query<RefRW<T>, RefRO<U>>()声明式查询:要求实体同时具备这两个组件
RefRW<LocalTransform>可读写包装,.ValueRW 读写数据
RefRO<MoveSpeed>只读包装,.ValueRO 只读数据(编译器/运行时知道你不会写,可做优化)
foreach由 Unity 源生成器展开成高效的 Chunk 遍历

partial 关键字是必须的:Entities 1.x 用 源生成器(Source Generator) 展开 SystemAPI.Query 的 foreach 语法,必须给类加 partial,否则编译报错(这是新手最常见的坑之一)。

5.2 Query 的三种写法

写法一:SystemAPI.Query(推荐,最简洁)
foreach (var (transform, speed) in
         SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveSpeed>>())
{
    transform.ValueRW.Position += ...;
}
写法二:EntityQuery + 手动遍历(精细控制)
public partial class MoveSystem : SystemBase
{
    protected override void OnUpdate()
    {
        // ① 定义 Query:必须有 LocalTransform + MoveSpeed
        var query = SystemAPI.QueryBuilder()
            .WithAll<LocalTransform, MoveSpeed>()       // 必须有
            .WithNone<DeadTag>()                        // 必须没有
            .WithAny<EnemyTag, PlayerTag>()             // 至少有一个
            .Build();

        // ② 转成 Chunk 数组遍历(最底层,可读可写)
        var chunks = query.ToArchetypeChunkArray(Allocator.Temp);
        var transformHandle = SystemAPI.GetComponentTypeHandle<LocalTransform>();
        var speedHandle = SystemAPI.GetComponentTypeHandle<MoveSpeed>(true); // true=只读

        float dt = SystemAPI.Time.DeltaTime;

        for (int c = 0; c < chunks.Length; c++)
        {
            var chunk = chunks[c];
            var transforms = chunk.GetNativeArray(transformHandle);
            var speeds = chunk.GetNativeArray(speedHandle);

            for (int i = 0; i < chunk.Count; i++)
            {
                transforms[i] = LocalTransform.FromPosition(
                    transforms[i].Position + new float3(speeds[i].Value * dt, 0, 0));
            }
        }
        chunks.Dispose();
    }
}
写法三:QueryBuilder 完整筛选(All / None / Any 组合)
var query = SystemAPI.QueryBuilder()
    .WithAll<LocalTransform, MoveSpeed>()       // 必须同时拥有
    .WithNone<DeadTag, DisabledTag>()           // 不能有
    .WithAny<EnemyTag, NeutralTag>()            // 至少有一个
    .WithOptions(EntityQueryOptions.IgnoreComponentEnabledState)  // 忽略启用状态
    .Build();

WithAll / WithNone / WithAny 的组合就是 ECS 的"筛选语言"——它决定了系统处理哪些实体。

5.3 IComponentData 的"启用状态"(Enableable Component)

Entities 1.x 支持可启用的组件:组件还在实体上,但"关掉"后系统就看不到它。这比 AddComponent/RemoveComponent(会触发结构变更)便宜得多。

// 定义:必须实现 IEnableableComponent
public struct StunnedTag : IComponentData, IEnableableComponent { }

// 使用:用 SystemAPI.SetComponentEnabled 开关它
SystemAPI.SetComponentEnabled<StunnedTag>(entity, true);    // 眩晕
SystemAPI.SetComponentEnabled<StunnedTag>(entity, false);   // 解除

// 查询时,被禁用的实体会自动跳过
foreach (var speed in SystemAPI.Query<RefRW<MoveSpeed>>()
         .WithAll<StunnedTag>())   // 只有启用状态的才会被处理
{
    // 这里只会处理"未眩晕"的实体(StunnedTag 被禁用)
}

什么时候用 Enableable Component 代替 Add/Remove?
频繁切换的状态(眩晕、沉默、无敌)用它——零结构变更、零 Chunk 迁移;一次性状态(死亡后永久移除)用 Add/Remove。

5.4 System 的两种基类

基类特点适用
SystemBase托管类,支持 foreach + 闭包,写法灵活,可方便操作托管数据逻辑复杂、需要和托管代码交互
ISystemstruct,完全 Burst 友好,性能最高纯计算型高频系统(移动、寻路、战斗)

ISystem 写法:

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

[BurstCompile]
public partial struct MoveSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        // 初始化(Burst 编译)
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float dt = SystemAPI.Time.DeltaTime;

        foreach (var (transform, speed) in
                 SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveSpeed>>())
        {
            transform.ValueRW.Position += new float3(speed.ValueRO.Value * dt, 0, 0);
        }
    }

    [BurstCompile]
    public void OnDestroy(ref SystemState state) { }
}

性能建议:能用 ISystem 就别用 SystemBase。ISystem 是 struct、全程 Burst、无托管分配,适合上万实体每帧处理。

5.5 SystemGroup:控制系统的执行顺序

问题:有 5 个系统,谁先跑谁后跑?

答案:用 [UpdateInGroup] 和 [UpdateBefore/After] 特性声明顺序。

// ① 自定义一个组
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial class MyGameGroup : ComponentSystemGroup { }

// ② 系统 A:在 MyGameGroup 里,且在 B 之前
[UpdateInGroup(typeof(MyGameGroup))]
[UpdateBefore(typeof(DamageSystem))]
public partial class MoveSystem : SystemBase { }

// ③ 系统 B:在 MyGameGroup 里,且在 A 之后
[UpdateInGroup(typeof(MyGameGroup))]
[UpdateAfter(typeof(MoveSystem))]
public partial class DamageSystem : SystemBase { }

Unity 内置的 SystemGroup 顺序(部分):

InitializationSystemGroup
(每帧开始:初始化/事件)

SimulationSystemGroup
(主逻辑:物理/移动/战斗/状态)

PresentationSystemGroup
(每帧结束:渲染/表现)

PhysicsSystemGroup
(物理)

TransformSystemGroup
(LocalTransform → LocalToWorld)

SystemGroup阶段典型系统
InitializationSystemGroup帧开始输入采集、事件分发
SimulationSystemGroup主逻辑移动、战斗、AI、物理
PresentationSystemGroup帧结束渲染同步、后处理

设计原则:"读数据 → 算逻辑 → 写结果"的系统顺序要严格。比如"移动"必须在"碰撞检测"之前,否则一帧的位置滞后。用特性声明顺序,别靠运气。


六、Job System + Burst:把多核榨干

6.1 为什么 ECS + Job 是绝配?

ECS 把数据排成了连续数组,这带来一个巨大红利:Chunk 之间互不依赖,可以直接丢给多核并行处理。

单个 System 处理 10000 个实体:
┌──────────────────────────────────────────────┐
│ Chunk 0(1000 个实体) → Worker 线程 0        │
│ Chunk 1(1000 个实体) → Worker 线程 1        │  ← 8 核并行
│ Chunk 2(1000 个实体) → Worker 线程 2        │
│ ...                                          │
│ Chunk 9(1000 个实体) → Worker 线程 N        │
└──────────────────────────────────────────────┘
8 核理论加速 ~8 倍(实际 4~6 倍,受内存带宽限制)

6.2 IJobEntity:最简洁的并行 Job

using Unity.Burst;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;

// ===== Job 定义 =====
[BurstCompile]
public partial struct MoveJob : IJobEntity
{
    public float DeltaTime;              // Job 里只能放值类型数据

    // Execute 会被自动对"每个匹配的实体"调用一次
    void Execute(ref LocalTransform transform, in MoveSpeed speed)
    {
        transform.Position += new float3(speed.Value * DeltaTime, 0, 0);
    }
}

// ===== System 调度 Job =====
[BurstCompile]
public partial class MoveSystem : SystemBase
{
    protected override void OnUpdate()
    {
        var job = new MoveJob
        {
            DeltaTime = SystemAPI.Time.DeltaTime
        };

        // Schedule:把 Job 丢给 Job 系统并行执行
        // Dependency:JobHandle 依赖链,保证前后顺序
        Dependency = job.Schedule(Dependency);

        // 可选:本帧结束前等它完成(如果后面还要读这些数据)
        // Dependency.Complete();
    }
}

逐项解释:

元素说明
IJobEntity1.x 的"实体遍历 Job",Execute 自动对每个匹配实体调用
ref LocalTransform可读写组件(相当于 RefRW)
in MoveSpeed只读组件(相当于 RefRO)
DependencySystemBase 的内置 JobHandle,用来串起依赖链
job.Schedule(Dependency)提交 Job,返回新的 Handle

依赖链的意义:

// System A 写位置
Dependency = moveJob.Schedule(Dependency);

// System B 读位置(必须等 A 完成)
Dependency = damageJob.Schedule(Dependency);   // 自动串行依赖

// 主线程最后统一等待
Dependency.Complete();

6.3 手动分 Chunk 并行:IJobChunk(精细控制)

[BurstCompile]
public struct MoveChunkJob : IJobChunk
{
    public float DeltaTime;
    public ComponentTypeHandle<LocalTransform> TransformHandle;  // 读写
    [Unity.Collections.ReadOnly]
    public ComponentTypeHandle<MoveSpeed> SpeedHandle;           // 只读

    public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex,
                        bool useEnabledMask, in Unity.Burst.Intrinsics.v128 chunkEnabledMask)
    {
        var transforms = chunk.GetNativeArray(TransformHandle);
        var speeds = chunk.GetNativeArray(SpeedHandle);

        for (int i = 0; i < chunk.Count; i++)
        {
            transforms[i] = LocalTransform.FromPosition(
                transforms[i].Position + new float3(speeds[i].Value * DeltaTime, 0, 0));
        }
    }
}

[BurstCompile]
public partial class MoveSystem : SystemBase
{
    protected override void OnUpdate()
    {
        var query = SystemAPI.QueryBuilder()
            .WithAll<LocalTransform, MoveSpeed>().Build();

        var job = new MoveChunkJob
        {
            DeltaTime = SystemAPI.Time.DeltaTime,
            TransformHandle = SystemAPI.GetComponentTypeHandle<LocalTransform>(),
            SpeedHandle = SystemAPI.GetComponentTypeHandle<MoveSpeed>(true)
        };

        // ScheduleParallel:按 Chunk 自动切分到多线程!
        Dependency = job.ScheduleParallel(query, Dependency);
    }
}

ScheduleParallel vs Schedule:

  • Schedule:整个查询单线程跑一个 Job;
  • ScheduleParallel:按 Chunk 自动切片,多线程并行——上万实体必用。

6.4 Burst Compiler:性能的最后一层加速

Burst 是什么:把 C#(限于值类型、数学运算、NativeArray)编译成接近 C++ 的机器码,并自动做 SIMD 向量化优化。

怎么用:在 System / Job 上标 [BurstCompile],就完成了。

[BurstCompile]                          // ← 一个特性和一把钥匙
public partial struct MoveJob : IJobEntity
{
    void Execute(ref LocalTransform transform, in MoveSpeed speed)
    {
        // 这个函数会被 Burst 编译成机器码,并自动 SIMD 优化
        transform.Position += new float3(speed.Value * DeltaTime, 0, 0);
    }
}

Burst 的限制(必须遵守):

限制说明
不能用托管类型string、class、List<T>、GameObject 全部禁止
不能用 Debug.Log只能用 UnityEngine.Debug.DrawLine(有限)或 Burst 专用日志
数学用 Unity.Mathematicsfloat3、math.sqrt、math.sin 等(比 Mathf 快很多)
不能抛异常用返回值/错误码传递错误
不能用 foreach 的托管枚举器用 NativeArray 的索引循环

Burst 的编译诊断:编辑器里编译后,Window → Burst → Show Burst Inspector 能看到汇编代码和 SIMD 优化结果。优化失败的会标红——那是你该优化的地方。

6.5 Jobs + Burst + ECS 三层加速的量化概念

层次优化手段量级收益(概念示意)
数据层ECS 连续数组Cache Miss 大幅减少,遍历常数极小
并行层Job System(多核)理论上限 ≈ 核数(实际 4~6 倍)
指令层Burst + SIMD单指令处理 4/8 个浮点,再快数倍
组合效果三者叠加相对 MonoBehaviour 常见有数十倍量级差异

注意:以上是原理层面的量级概念,不是实测基准。真机真实差异取决于你的逻辑复杂度、数据量、内存带宽、以及是否真的是计算瓶颈(如果是渲染瓶颈,ECS 也救不了你的 Draw Call)。永远用 Profiler 验证,不要相信任何"ECS 快 100 倍"的宣传语。


七、深入内存布局:Archetype、Chunk、SharedComponent、DynamicBuffer、BlobAsset

这一章是"从会用 ECS 到能设计 ECS"的分水岭。不懂 Archetype 和 Chunk,你的性能优化就是盲人摸象。

7.1 Archetype(原型):组件组合的"类型签名"

定义:一组固定的组件组合 = 一个 Archetype。

// 三种 Archetype(三种"实体种类")
Archetype A: MoveSpeed + LocalTransform + EnemyTag          → 敌人
Archetype B: MoveSpeed + LocalTransform + PlayerTag + Health → 玩家
Archetype C: LocalTransform + RenderMesh                      → 静态装饰

关键规则:

  1. 实体一旦创建,它的 Archetype 就确定了(拥有哪些组件);
  2. AddComponent / RemoveComponent 会改变 Archetype → 实体必须从旧 Chunk 搬到新 Chunk(这叫 结构变更 Structural Change,很贵!);
  3. 因此,高频的"加/减组件"操作要用 Enableable Component 替代(见 5.3)。

结构变更的成本(为什么贵):

给实体 X 加一个组件:
  ① 在旧 Chunk 里删除该实体(后面的数据前移)
  ② 在新 Archetype 的 Chunk 里追加该实体
  ③ 复制所有已有组件的值
  → 一次 AddComponent = 两次数据搬移 + 可能的 Chunk 分配

实战铁律:别在 Update 里每帧 AddComponent/RemoveComponent。用 Enableable Component,或者"预分配所有可能用到的组件"。

7.2 Chunk(块):数据实际存放的"抽屉"

定义:Chunk 是一块固定大小(16KB)的内存,存放同一 Archetype 的一批实体数据。

一个 Archetype 的所有实体,被分装在若干个 Chunk 里:
┌──────────────── Chunk(16KB)────────────────┐
│ Entity[0]: MoveSpeed=1.0, Transform=pos0      │
│ Entity[1]: MoveSpeed=2.5, Transform=pos1      │
│ Entity[2]: MoveSpeed=0.8, Transform=pos2      │
│ ...(一 Chunk 约容纳 100~400 个实体,看组件大小)│
└───────────────────────────────────────────────┘
┌──────────────── Chunk 2(16KB)───────────────┐
│ Entity[100]: ...                               │
└───────────────────────────────────────────────┘

为什么 Chunk 重要?

特性意义
16KB 固定大小一个 Chunk 的数据能装进 L1 缓存(典型 32KB),遍历时几乎零 Miss
同 Archetype 同 Chunk同 Chunk 内组件布局完全一致 → 可以无分支顺序遍历
Chunk 是并行的最小单位ScheduleParallel 按 Chunk 切分给不同线程,Chunk 之间无依赖

这解释了 ECS 性能的物理根源:16KB 的 Chunk 正好塞进 CPU 缓存,顺序遍历命中率接近 100%。

7.3 SharedComponentData:共享只读数据,不占 Chunk 空间

问题:10000 个敌人共享同一个材质、同一个网格——如果每个实体都存一份材质引用,白白浪费内存。

解法:ISharedComponentData(共享组件)。相同值的实体被分到同一个 Chunk,且该值只存一份。

using Unity.Entities;
using Unity.Rendering;

// 自定义共享组件:比如"阵营"
public struct Faction : ISharedComponentData
{
    public int Value;      // 0=玩家阵营,1=敌方阵营
}

// 使用
em.AddSharedComponent(entity, new Faction { Value = 1 });

关键特性与坑:

特性说明
只存一份相同值的实体共享同一个值实例
按值分组 Chunk同 Faction 值的实体在同一个 Chunk 里
修改代价极大改一个实体的 SharedComponent 值 → 它会换 Chunk(等同于结构变更)
不能用 Burst 直接读SharedComponent 需要通过 Managed 接口访问,不能在 Burst Job 里用

使用场景:材质、网格、渲染层(RenderMesh 就是共享组件)、阵营、区域分组。绝不要在每帧改 SharedComponent。

7.4 DynamicBuffer:实体上的"可变长数组"

问题:一个实体需要存"不定长"数据(比如背包物品、路径点列表、伤害历史),但 IComponentData 只能存固定大小的值。

解法:DynamicBuffer<T>——附着在实体上的可变长数组。

using Unity.Entities;

// 定义 Buffer(元素必须是 struct)
public struct PathPoint : IBufferElementData
{
    public float3 Position;
}

// 使用
var entity = em.CreateEntity(typeof(PathPoint));

var buffer = em.GetBuffer<PathPoint>(entity);
buffer.Add(new PathPoint { Position = new float3(1, 0, 0) });   // 追加
buffer.Add(new PathPoint { Position = new float3(2, 0, 0) });
buffer[0] = new PathPoint { Position = new float3(0, 0, 0) };  // 修改

// 遍历(Burst 友好)
foreach (var point in buffer)
{
    // ...
}

DynamicBuffer vs 数组对比:

DynamicBufferNativeArray 字段
存储位置在 Chunk 内(连续)堆上独立分配
扩容自动(溢出到外部 Chunk)手动重新分配
Burst✅ 支持✅ 支持
适合每实体不定长数据(路径/背包)全局共享的固定数据

7.5 BlobAsset:不可变的共享二进制数据

问题:一份"大而只读"的数据(比如一张导航网格、一棵行为树、一份配置表),要被很多实体共享,又不想复制。

解法:BlobAsset——在非托管内存里分配一次,多处引用,永不修改(不可变)。

using Unity.Entities;
using Unity.Collections;

// 定义 Blob 结构
public struct GameConfig
{
    public float Gravity;
    public float JumpForce;
    public int MaxLevel;
}

// 创建 Blob(一般用 BlobBuilder)
BlobBuilder builder = new BlobBuilder(Allocator.Temp);
ref GameConfig config = ref builder.ConstructRoot<GameConfig>();
config.Gravity = 9.8f;
config.JumpForce = 5f;
config.MaxLevel = 100;

BlobAssetReference<GameConfig> blobRef = builder.ToBlobAssetReference();
builder.Dispose();

// 存到单例组件里(所有系统共享)
var singletonEntity = em.CreateEntity(typeof(ConfigSingleton));
em.SetComponentData(singletonEntity, new ConfigSingleton { Config = blobRef });

为什么需要 BlobAsset?

原因说明
零复制共享10000 个实体引用同一个 Blob,只占一份内存
不可变 = 线程安全只读,多线程随便读
Burst 可用天然适合 Burst Job
GiST 序列化可以序列化成二进制,方便 AB 加载

典型用途:导航网格、行为树、配置表、关卡数据。这是"大数据共享"的标准方案。

7.6 内存布局全景图

全局数据

SharedComponents(按值分组,只存一份)

Archetype B(MoveSpeed + LocalTransform + PlayerTag + Health)

Archetype A(MoveSpeed + LocalTransform + EnemyTag)

引用

附着

Chunk A-1(16KB)
Entity 0..N

Chunk A-2(16KB)
Entity N..2N

Chunk B-1(16KB)
Entity X..Y

Faction=0 的实体
→ 同一批 Chunk

Faction=1 的实体
→ 另一批 Chunk

BlobAsset(不可变共享数据)
配置/导航/行为树

DynamicBuffer(每实体变长数组)
路径点/背包

ASCII 版(关键概念浓缩):

World
├─ Archetype A ──▶ Chunk A-1 [16KB: 实体0..N] ──▶ Chunk A-2 [16KB: 实体N..2N]
├─ Archetype B ──▶ Chunk B-1 [16KB: 实体X..Y]
├─ SharedComponent(值=材质A)──▶ 所有该值的实体归到同一批 Chunk(值只存一份)
├─ DynamicBuffer ──▶ 附着在实体上,可变长数组(在 Chunk 内/外溢出区)
└─ BlobAsset ──▶ 非托管内存中的不可变大对象(被多处引用,零复制)

7.7 这一章的核心结论(背下来)

  1. Archetype = 组件组合 = 实体类型;加/减组件 = 结构变更 = 昂贵;
  2. Chunk = 16KB 内存块 = 缓存友好的根源 = 并行的最小单位;
  3. SharedComponent = 按值分组 = 只存一份 = 不能频繁改 = 不能进 Burst;
  4. DynamicBuffer = 每实体的变长数组,适合路径/背包;
  5. BlobAsset = 不可变全局大数据,适合配置/导航。

八、EntityCommandBuffer、Singleton 与 Aspect

8.1 EntityCommandBuffer(ECB):延迟执行的结构变更

问题:在 Job 里(多线程中)不能直接调用 EntityManager(它不是线程安全的),但很多逻辑需要在遍历中"创建实体、销毁实体、加组件"。

解法:EntityCommandBuffer(ECB)——把结构变更"记下来",等所有 Job 结束后统一在主线程执行。

using Unity.Entities;
using Unity.Collections;
using Unity.Mathematics;
using Unity.Transforms;

// Job 里记录"要做什么",而不是"立刻做"
[BurstCompile]
public partial struct SpawnJob : IJobEntity
{
    public EntityCommandBuffer ECB;       // 注意:ECB 是"并行写"的安全对象

    void Execute(ref LocalTransform transform, in Spawner spawner)
    {
        // 记录:在这个位置创建一个新实体(真正执行在主线程)
        var newEntity = ECB.CreateEntity();
        ECB.SetComponent(newEntity, LocalTransform.FromPosition(transform.Position));
        ECB.SetComponent(newEntity, new MoveSpeed { Value = 2f });
        ECB.AddComponent<EnemyTag>(newEntity);
    }
}

public partial class SpawnSystem : SystemBase
{
    private EntityCommandBufferSystem _ecbSystem;

    protected override void OnCreate()
    {
        // 使用 Unity 内置的 ECB 系统(在帧末自动播放缓冲)
        _ecbSystem = World.GetOrCreateSystemManaged<BeginSimulationEntityCommandBufferSystem>();
    }

    protected override void OnUpdate()
    {
        var ecb = _ecbSystem.CreateCommandBuffer();       // 创建缓冲

        var job = new SpawnJob { ECB = ecb };
        Dependency = job.ScheduleParallel(Dependency);

        // 注册依赖:确保 Job 完成后 ECB 才播放
        _ecbSystem.AddJobHandleForProducer(Dependency);
    }
}

ECB 的三种创建方式(关键区别):

方式用途线程安全
ecbSystem.CreateCommandBuffer()主线程用(普通)主线程
ecbSystem.CreateCommandBuffer().AsParallelWriter()Job 并行用✅ 多线程安全
EntityManager.CreateCommandBuffer()手动管理的独立缓冲主线程

Burst Job 里创建实体的唯一正确姿势就是 ECB。直接 EntityManager.CreateEntity 在多线程里会崩。

ECB 的执行时机:

System.OnUpdate
记录 ECB 指令

Job 并行执行
(写入 ECB 缓冲)

所有 Job 完成
(Sync Point)

ECBSystem 播放缓冲
→ EntityManager 真正执行
创建/销毁/加组件

下一帧继续

8.2 Singleton(单例组件):全局数据

问题:有些数据是全局唯一的(游戏时间、玩家分数、配置),不需要"每个实体都有一份"。

解法:Singleton——全局只有一个实体持有该组件。

// 定义单例组件
public struct GameState : IComponentData
{
    public float GameTime;
    public int Score;
}

// 创建单例实体(一般放在启动系统里)
public partial class GameInitSystem : SystemBase
{
    protected override void OnCreate()
    {
        var entity = EntityManager.CreateEntity(typeof(GameState));
        EntityManager.SetComponentData(entity, new GameState
        {
            GameTime = 0,
            Score = 0
        });
    }
}

// 读取单例(任何系统都能读)
public partial class ScoreSystem : SystemBase
{
    protected override void OnUpdate()
    {
        // SystemAPI.GetSingleton<GameState>()  读取
        var state = SystemAPI.GetSingleton<GameState>();
        // ...使用

        // SystemAPI.SetSingleton<GameState>(newValue)  写入
    }
}

Singleton 的扩展读写:

// RefRW 读写(推荐,性能更好)
foreach (var state in SystemAPI.Query<RefRW<GameState>>())
{
    state.ValueRW.GameTime += SystemAPI.Time.DeltaTime;
}

// 或者用 SingletonAspect
var stateAspect = SystemAPI.GetSingletonRW<GameState>();
stateAspect.ValueRW.Score += 10;

注意:GetSingleton 如果没找到单例会抛异常。用 TryGetSingleton 做安全判断,或保证启动系统先创建它(用 [UpdateInGroup(typeof(InitializationSystemGroup))] + [UpdateBefore] 保证顺序)。

8.3 Aspect:把"一组组件"打包成参数

问题:一个系统要操作 4~5 个组件,foreach (var (a, b, c, d) in Query<...>()) 的元组越来越长,代码难读。

解法:IAspect——把多个组件访问包装成一个结构。

using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

// 定义 Aspect:把"位置 + 移动速度 + 血量"打包
public struct MovingUnitAspect : IAspect
{
    public readonly RefRW<LocalTransform> Transform;   // 可读写
    public readonly RefRO<MoveSpeed> Speed;            // 只读
    public readonly RefRW<Health> Health;              // 可读写

    // 在 Aspect 里直接定义"操作",把逻辑内聚
    public void Move(float deltaTime)
    {
        Transform.ValueRW.Position += new float3(Speed.ValueRO.Value * deltaTime, 0, 0);
    }

    public void TakeDamage(float damage)
    {
        Health.ValueRW.Current -= damage;
    }
}

// 系统里用 Aspect,代码一下就清爽了
public partial class UnitSystem : SystemBase
{
    protected override void OnUpdate()
    {
        float dt = SystemAPI.Time.DeltaTime;

        foreach (var unit in SystemAPI.Query<MovingUnitAspect>())
        {
            unit.Move(dt);          // 一行搞定
        }
    }
}

Aspect 的核心价值:

价值说明
减少样板代码不用每次都写 5 个 RefRW/RefRO
逻辑内聚"移动"这个操作定义在 Aspect 里,而不是散落在系统里
可组合不同系统可以复用同一个 Aspect

这是 1.x 官方推荐的"组件访问模式",比元组写法可读性高一个量级。

8.4 数据流总览图(本章总结)

④ 输出

③ 结构变更提交

② System 处理(本帧)

① 输入

玩家输入 / 网络消息 / 定时器

System A:读输入,写组件

Job 并行处理
(IJobEntity / ScheduleParallel)

ECB 记录结构变更
(创建/销毁/加组件)

ECBSystem 播放缓冲
→ EntityManager 真正执行

结构变更后:实体换 Chunk

LocalTransform → LocalToWorld

Entities Graphics 批量渲染


九、完整实战:10000 个单位同屏移动与渲染

这一章给你一套完整的、可以直接放进项目的代码,实现"10000 个立方体单位同屏移动",并在最后给出 GameObject 版 vs ECS 版的对比方法。

9.1 项目结构

Assets/
├─ Scripts/
│  ├─ Components/
│  │   ├─ MoveSpeed.cs         # 移动速度组件
│  │   ├─ EnemyTag.cs          # 敌人标签
│  │   └─ SpawnerConfig.cs     # 生成配置(单例)
│  ├─ Systems/
│  │   ├─ SpawnSystem.cs       # 生成 10000 个单位
│  │   └─ MoveSystem.cs        # 每帧移动
│  └─ Rendering/
│      └─ RenderSetup.cs       # 给实体挂渲染组件
└─ SubScene/                    # 如果用编辑器工作流

9.2 组件定义

// Components/MoveSpeed.cs
using Unity.Entities;

public struct MoveSpeed : IComponentData
{
    public float Value;       // 每秒移动速度
}

// Components/EnemyTag.cs
using Unity.Entities;

public struct EnemyTag : IComponentData { }

// Components/SpawnerConfig.cs
using Unity.Entities;

public struct SpawnerConfig : IComponentData
{
    public int Count;          // 生成数量
    public float Speed;        // 移动速度
    public float Spacing;      // 间距
}

9.3 生成系统(CreateArchetype 批量创建 + 挂渲染)

// Systems/SpawnSystem.cs
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Rendering;
using Unity.Transforms;
using UnityEngine;
using UnityEngine.Rendering;

[UpdateInGroup(typeof(InitializationSystemGroup))]
public partial class SpawnSystem : SystemBase
{
    protected override void OnCreate()
    {
        // 只创建一次配置单例
        var configEntity = EntityManager.CreateEntity(typeof(SpawnerConfig));
        EntityManager.SetComponentData(configEntity, new SpawnerConfig
        {
            Count = 10000,
            Speed = 3f,
            Spacing = 0.8f
        });
    }

    protected override void OnStartRunning()
    {
        var config = SystemAPI.GetSingleton<SpawnerConfig>();

        // ===== ① 准备渲染资源(网格 + 材质)=====
        var mesh = GetCubeMesh();                    // 内置立方体网格
        var material = new Material(Shader.Find("Universal Render Pipeline/Lit"));
        material.color = Color.cyan;

        // ===== ② 定义 Archetype =====
        var archetype = EntityManager.CreateArchetype(
            typeof(MoveSpeed),
            typeof(LocalTransform),
            typeof(LocalToWorld),
            typeof(EnemyTag),
            typeof(RenderMesh),
            typeof(RenderBounds),
            typeof(MaterialMeshInfo)                 // 旧版;1.x 用 RenderMeshArray
        );

        // ===== ③ 一次性创建 10000 个实体 =====
        var entities = EntityManager.CreateEntity(archetype, config.Count, Allocator.Temp);

        // ===== ④ 批量填数据 =====
        var speeds = new NativeArray<MoveSpeed>(config.Count, Allocator.Temp);
        var transforms = new NativeArray<LocalTransform>(config.Count, Allocator.Temp);
        var renderMeshes = new NativeArray<RenderMesh>(config.Count, Allocator.Temp);
        var bounds = new NativeArray<RenderBounds>(config.Count, Allocator.Temp);

        int side = Mathf.CeilToInt(Mathf.Sqrt(config.Count));   // 摆成方阵

        for (int i = 0; i < config.Count; i++)
        {
            float x = (i % side) * config.Spacing;
            float z = (i / side) * config.Spacing;

            speeds[i] = new MoveSpeed { Value = config.Speed };
            transforms[i] = LocalTransform.FromPosition(new float3(x, 0, z));
            renderMeshes[i] = new RenderMesh
            {
                mesh = mesh,
                material = material,
                submesh = 0,
                layer = 0,
                castShadows = ShadowCastingMode.Off,
                receiveShadows = false
            };
            bounds[i] = new RenderBounds
            {
                Value = new AABB
                {
                    Center = float3.zero,
                    Extents = new float3(0.5f, 0.5f, 0.5f)
                }
            };
        }

        // ===== ⑤ 批量写入(一次结构变更都不发生)=====
        EntityManager.CopyComponentDataArray(speeds, entities);
        EntityManager.CopyComponentDataArray(transforms, entities);
        EntityManager.CopyComponentDataArray(renderMeshes, entities);
        EntityManager.CopyComponentDataArray(bounds, entities);

        speeds.Dispose();
        transforms.Dispose();
        renderMeshes.Dispose();
        bounds.Dispose();
        entities.Dispose();
    }

    private Mesh GetCubeMesh()
    {
        // 用 Unity 内置的 Cube 网格(Resources 里自带)
        var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
        var mesh = go.GetComponent<MeshFilter>().sharedMesh;
        Object.Destroy(go);       // 只取网格,不保留对象
        return mesh;
    }
}

说明:Entities 1.x 的渲染组件在不同小版本里有差异(RenderMesh vs RenderMeshArray + MaterialMeshInfo)。如果编译报"类型不存在",请在你的 Unity.Rendering 程序集里查找正确类型名(Window → Entities → Entities Hierarchy 或查官方手册)。核心思想不变:给实体挂"网格+材质+包围盒"三件套。

9.4 移动系统(IJobEntity + Burst + ScheduleParallel)

// Systems/MoveSystem.cs
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

[BurstCompile]
public partial struct MoveJob : IJobEntity
{
    public float DeltaTime;

    void Execute(ref LocalTransform transform, in MoveSpeed speed)
    {
        // 沿 X 轴移动,超出边界后回到起点(循环移动)
        float3 pos = transform.Position;
        pos.x += speed.Value * DeltaTime;
        if (pos.x > 100f) pos.x = 0f;
        transform.Position = pos;
    }
}

[BurstCompile]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial class MoveSystem : SystemBase
{
    protected override void OnUpdate()
    {
        var job = new MoveJob
        {
            DeltaTime = SystemAPI.Time.DeltaTime
        };

        // 按 Chunk 自动并行!
        Dependency = job.ScheduleParallel(Dependency);
    }
}

9.5 完整执行流程图

游戏启动

SpawnSystem.OnCreate
创建 SpawnerConfig 单例

SpawnSystem.OnStartRunning
① 准备 Mesh + Material
② 定义 Archetype
③ CreateEntity(archetype, 10000)
④ 批量填组件数据

10000 个实体就位
分布在多个 16KB Chunk

每帧:MoveSystem.OnUpdate

MoveJob.ScheduleParallel
→ 按 Chunk 切分到多核
→ Burst 编译机器码

LocalTransform 批量更新

TransformSystemGroup
LocalTransform → LocalToWorld

Entities Graphics
批量提交渲染(Burst + Instancing)

屏幕显示 10000 个单位

ASCII 版(时序压缩):

启动 ──▶ [SpawnSystem] 批量创建 10000 实体(一次结构变更)──▶ [Chunk 就绪]
                                                                     │
每一帧:                                                             ▼
  [MoveSystem] ──ScheduleParallel──▶ [Worker 线程 0..N] ──▶ LocalTransform 更新
                                             │
                                             ▼
                          [TransformSystem] LocalTransform → LocalToWorld
                                             │
                                             ▼
                          [Entities Graphics] 批量渲染(一次 Draw 提交)

9.6 性能对比:GameObject 版 vs ECS 版

对照组写法(GameObject 版):

// GameObject 版:同样 10000 个单位,同样每帧移动
public class GameObjectMover : MonoBehaviour
{
    void Start()
    {
        for (int i = 0; i < 10000; i++)
        {
            var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
            go.name = "Unit_" + i;
            go.transform.position = new Vector3(i % 100 * 0.8f, 0, i / 100 * 0.8f);
        }
    }

    void Update()
    {
        // 逐一移动(这才是真实的 OOP 成本)
        foreach (var t in _allTransforms)
            t.position += Vector3.right * 3f * Time.deltaTime;
    }
}

怎么自己测(这是最重要的部分):

步骤做法
① 固定环境同一台真机、同一 Unity 版本、关闭 VSync
② 分别跑GameObject 版跑 60 秒,记录 Profiler 的 FPS/CPU/GC;ECS 版同样跑 60 秒
③ 看关键指标CPU 主线程时间(ECS 应显著低)、GC Alloc(ECS 应基本为 0)、FPS 稳定性
④ 加量测试从 1000 → 10000 → 50000,看曲线拐点(GameObject 版通常在万级就崩)

原理层面的预期差异(非实测,请以你的机器为准):

指标GameObject 版(预期)ECS 版(预期)
单帧 CPU 逻辑耗时随数量线性暴涨(Cache Miss + 虚方法调用)增长平缓(连续数组 + Burst + 并行)
GC Alloc高(Transform 访问、装箱、事件)0(值类型,无托管分配)
渲染提交10000 个 Renderer → 大量 Draw CallEntities Graphics 批量合批
万级可扩展性3 万以上明显掉帧数十万仍可接受(视逻辑复杂度)

务必自己实测:把上面两套代码都跑起来,用 Profiler 对比。只有你亲眼看到的数字才是真的。任何博客里的"快 100 倍"都不该被直接采信。

9.7 调试 ECS 的必备工具

工具打开方式用途
Entities HierarchyWindow → Entities → Hierarchy查看运行时所有实体、组件、Chunk
Systems WindowWindow → Entities → Systems看所有 System 的耗时、执行顺序
Entities JournalingWindow → Entities → Journaling记录结构变更历史(谁创建/销毁了什么)
Profiler常规 ProfilerSystem 耗时、GC、Job 时间线
Burst InspectorWindow → Burst → Show Burst Inspector看汇编与 SIMD 优化

Entities Journaling 是排查"实体莫名消失"的神器:它会记录每一次创建/销毁/加组件的调用栈。


十、ECS 设计原则与从 OOP 迁移实战

会用 API 不等于会设计 ECS。这一章讲的是"架构层面的纪律"——90% 的 ECS 项目失败,都败在组件拆分和系统划分上。

10.1 原则一:组件要"细",不要"胖"

错误做法(把整个角色塞进一个组件):

// ❌ 反例:一个组件装完所有数据
public struct CharacterData : IComponentData
{
    public float Speed;
    public float JumpForce;
    public int Hp, MaxHp;
    public float Attack;
    public SkillData[] Skills;        // 还想放数组
    public EquipmentData Equipment;   // 还想放装备
}

问题:所有系统都得读这一个大组件 → 缓存里塞满用不到的数据 → ECS 的优势全丢;而且改任何一项都要动整个组件。

正确做法(按"谁读/谁写"拆分):

// ✅ 正例:按使用场景拆成多个小组件
public struct MoveSpeed : IComponentData { public float Value; }       // 只有移动系统读
public struct Health : IComponentData { public float Cur, Max; }     // 只有战斗系统读
public struct Attack : IComponentData { public float Value; }        // 只有攻击系统读
public struct JumpForce : IComponentData { public float Value; }     // 只有跳跃系统读

拆分的判断标准:

标准说明
同一个系统读写的放一起移动系统只读 MoveSpeed,不要让它"看到"血量
不同系统独立访问的拆开血量归战斗系统,速度归移动系统
高频修改的单独放每帧都变的(位置)和几乎不变的(最大血量)分开
标签用空组件PlayerTag、DeadTag、BossTag 不占任何数据

黄金法则:组件的大小应当"刚好够一个系统用"。如果某个系统读了一个大组件里的 1 个字段,说明这个组件该拆了。

10.2 原则二:系统要"单一职责"

错误做法(一个大系统干所有事):

// ❌ 反例:一个系统里写了移动、战斗、UI、AI
public partial class GameSystem : SystemBase
{
    protected override void OnUpdate()
    {
        // 移动
        foreach (...) { ... }
        // 战斗
        foreach (...) { ... }
        // UI 更新
        // AI 决策
    }
}

正确做法(每个系统只干一件事):

public partial class MoveSystem : SystemBase { ... }        // 只移动
public partial class DamageSystem : SystemBase { ... }      // 只结算伤害
public partial class DeathSystem : SystemBase { ... }       // 只处理死亡
public partial class AISystem : SystemBase { ... }          // 只做决策

系统划分的判断标准:

标准说明
一个系统 = 一个动词“移动”、“受伤”、“生成”、"销毁"各是一个系统
系统之间靠组件通信A 系统写组件,B 系统读组件,绝不互相直接调用
系统无状态不持有"上一帧的数据",所有状态都在组件里
系统可独立开关关掉某个系统,游戏仍能跑(只是缺某个功能)

10.3 原则三:数据用"值类型",逻辑用"系统"

反模式后果正确做法
IComponentData 里放 class / string无法 Burst、无法 Job、GC 爆炸用 FixedString、DynamicBuffer、BlobAsset
系统里 new 对象每帧 GC预分配 / 对象池 / NativeArray
系统里 FindObjectOfType极慢用 Query
用 Vector3 做高频运算Burst 不友好用 float3(Unity.Mathematics)
实体上存 GameObject 引用破坏 ECS 纯数据模型用 Entity 引用或 Blade 组件

10.4 从 OOP 迁移到 ECS 的五步法

假设你有一个 Enemy : MonoBehaviour(含移动、血量、攻击),要迁移到 ECS:

第1步:盘点数据
列出 Enemy 上所有字段

第2步:拆分组件
按'系统归属'切成小组件

第3步:定义标签
PlayerTag / EnemyTag / DeadTag

第4步:拆分系统
按'行为'拆成移动/伤害/死亡系统

第5步:接渲染
挂 RenderMesh,用 Entities Graphics

验证:Profiler 对比优化前后

第 1 步:盘点原有字段

// 原来的 Enemy
public class Enemy : MonoBehaviour
{
    public float speed;         // → MoveSpeed
    public Vector3 position;    // → LocalTransform
    public float hp, maxHp;     // → Health
    public float attack;        // → Attack
    public bool isDead;         // → DeadTag(空组件)
    public string enemyName;    // → 不适合 ECS,放配置表/BlobAsset
    public GameObject target;   // → Entity 引用(TargetEntity 组件)
}

第 2 步:拆成组件

public struct MoveSpeed : IComponentData { public float Value; }
public struct Health : IComponentData { public float Cur, Max; }
public struct Attack : IComponentData { public float Value; }
public struct EnemyTag : IComponentData { }
public struct DeadTag : IComponentData { }
public struct TargetEntity : IComponentData { public Entity Value; }

第 3~4 步:写系统

// 移动系统(读 MoveSpeed,写 LocalTransform)
[BurstCompile]
public partial struct EnemyMoveJob : IJobEntity
{
    void Execute(ref LocalTransform t, in MoveSpeed s, in EnemyTag tag)
    {
        t.Position += new float3(s.Value * 0.016f, 0, 0);
    }
}

// 受击系统(读 DamageEvent,写 Health)
[BurstCompile]
public partial struct ApplyDamageJob : IJobEntity
{
    void Execute(ref Health hp, in DamageEvent dmg)
    {
        hp.Cur -= dmg.Amount;
        if (hp.Cur <= 0) hp.Cur = 0;
    }
}

第 5 步:接渲染

RenderMesh(或 1.x 的 MaterialMeshInfo + RenderMeshArray)给实体挂上网格和材质,Entities Graphics 会自动批量渲染。

迁移策略建议:不要一次性全项目迁移。挑一个"实体多、逻辑简单"的模块(比如子弹/敌人海/粒子)先迁,跑通后再扩。全量迁移 ECS 是老手才做的事,新手容易被劝退。

10.5 ECS 项目结构推荐(可复用模板)

Assets/
├─ ECS/
│  ├─ Components/              # 所有 IComponentData
│  │   ├─ Movement/
│  │   ├─ Combat/
│  │   └─ Common/
│  ├─ Systems/                 # 所有 System
│  │   ├─ Initialization/      # 启动期
│  │   ├─ Simulation/          # 主逻辑
│  │   └─ Presentation/        # 渲染相关
│  ├─ Aspects/                 # 组件访问封装
│  ├─ Authoring/               # MonoBehaviour + Baker(编辑器侧)
│  ├─ Configs/                 # BlobAsset 配置
│  └─ Utils/                   # 工具(ECB 扩展等)
├─ SubScenes/                  # 场景烘焙
└─ Game/                       # 非 ECS 部分(UI/流程/存档)—— 保留 OOP

注意最后一行:UI、流程控制、存档、编辑器工具,继续用 OOP。ECS + OOP 混合架构才是正确姿势——纯 ECS 项目极其罕见。


十一、手写一个不依赖 DOTS 的 ECS

为什么要手写? 因为 DOTS 太"黑盒",很多人用了很久还是不懂 ECS 的内核。自己实现一遍,你才真正理解"连续数组 + 系统遍历 + Archetype"是什么。

下面这套实现是纯 C#(.NET / Unity 均可),零外部依赖,几十行核心代码,帮你彻底看透 ECS。

11.1 架构设计

World
管理所有实体

ComponentStore
按类型存连续数组

EntityRegistry
实体ID + 组件掩码

System
遍历匹配实体

11.2 核心实现(完整可运行)

using System;
using System.Collections.Generic;

// ==================== ① 类型 ID 分配 ====================
public static class ComponentType
{
    private static readonly Dictionary<Type, int> _map = new();
    private static int _next = 0;

    public static int GetId(Type t)
    {
        if (!_map.TryGetValue(t, out var id))
        {
            id = _next++;
            _map[t] = id;
        }
        return id;
    }

    public static int GetId<T>() => GetId(typeof(T));
}

// ==================== ② 组件存储(连续数组) ====================
public class ComponentStore<T>
{
    private readonly List<T> _data = new();          // 连续数组

    public T Get(int entityIndex) => _data[entityIndex];
    public void Set(int entityIndex, T value) => _data[entityIndex] = value;

    public int Add(T value)
    {
        _data.Add(value);
        return _data.Count - 1;                       // 返回存储索引
    }

    public void RemoveAt(int entityIndex)
    {
        // 简化:直接标记为"占位",真实实现会用"交换删除法"
        _data[entityIndex] = default;
    }
}

// ==================== ③ 实体 ====================
public struct Entity
{
    public int Index;          // 实体索引
    public int Version;        // 版本(防止复用 ID 时误引用)
}

// ==================== ④ World(世界) ====================
public class World
{
    private readonly List<int> _versions = new();                        // 每个实体的版本
    private readonly Dictionary<int, ulong> _masks = new();              // 实体 → 组件掩码
    private readonly Dictionary<int, Dictionary<int, int>> _map = new(); // 实体 → (类型ID → 存储索引)
    private readonly Dictionary<int, object> _stores = new();            // 类型ID → ComponentStore

    private readonly List<int> _freeList = new();                        // 回收的空位

    // 创建实体
    public Entity CreateEntity()
    {
        int index;
        if (_freeList.Count > 0)
        {
            index = _freeList[_freeList.Count - 1];
            _freeList.RemoveAt(_freeList.Count - 1);
        }
        else
        {
            index = _versions.Count;
            _versions.Add(0);
        }
        _masks[index] = 0;
        _map[index] = new Dictionary<int, int>();
        return new Entity { Index = index, Version = _versions[index] };
    }

    // 销毁实体
    public void DestroyEntity(Entity e)
    {
        if (!IsAlive(e)) return;
        _masks[e.Index] = 0;
        _map[e.Index].Clear();
        _versions[e.Index]++;
        _freeList.Add(e.Index);
    }

    public bool IsAlive(Entity e) =>
        e.Index >= 0 && e.Index < _versions.Count && _versions[e.Index] == e.Version;

    // 加组件
    public void AddComponent<T>(Entity e, T value)
    {
        int typeId = ComponentType.GetId<T>();
        ulong bit = 1UL << typeId;

        if (!_stores.TryGetValue(typeId, out var storeObj))
        {
            storeObj = new ComponentStore<T>();
            _stores[typeId] = storeObj;
        }
        var store = (ComponentStore<T>)storeObj;
        int storageIndex = store.Add(value);

        _map[e.Index][typeId] = storageIndex;
        _masks[e.Index] |= bit;
    }

    // 读组件
    public T GetComponent<T>(Entity e)
    {
        int typeId = ComponentType.GetId<T>();
        var store = (ComponentStore<T>)_stores[typeId];
        return store.Get(_map[e.Index][typeId]);
    }

    // 写组件
    public void SetComponent<T>(Entity e, T value)
    {
        int typeId = ComponentType.GetId<T>();
        var store = (ComponentStore<T>)_stores[typeId];
        store.Set(_map[e.Index][typeId], value);
    }

    // 是否有某组件
    public bool HasComponent<T>(Entity e)
    {
        int typeId = ComponentType.GetId<T>();
        if (typeId >= 64) return false;                // 简化:只支持 64 种组件
        return (_masks[e.Index] & (1UL << typeId)) != 0;
    }

    // 查询:返回所有"拥有指定组件组合"的实体
    public List<Entity> Query(params Type[] required)
    {
        ulong mask = 0;
        foreach (var t in required) mask |= (1UL << ComponentType.GetId(t));

        var result = new List<Entity>();
        foreach (var kv in _masks)
        {
            int idx = kv.Key;
            // 必须"包含"所有要求的位
            if ((kv.Value & mask) == mask)
                result.Add(new Entity { Index = idx, Version = _versions[idx] });
        }
        return result;
    }
}

11.3 在 Unity 里用它跑 10000 个实体

using UnityEngine;
using System.Collections.Generic;

// 组件
public struct Position { public Vector3 Value; }
public struct MoveSpeed { public float Value; }

public class SimpleECSDemo : MonoBehaviour
{
    private World _world;
    private List<Entity> _entities = new();

    void Start()
    {
        _world = new World();

        // 创建 10000 个实体(每个挂 Position + MoveSpeed)
        for (int i = 0; i < 10000; i++)
        {
            var e = _world.CreateEntity();
            _world.AddComponent(e, new Position
            {
                Value = new Vector3(i % 100, 0, i / 100)
            });
            _world.AddComponent(e, new MoveSpeed { Value = 2f });
            _entities.Add(e);
        }
    }

    void Update()
    {
        float dt = Time.deltaTime;

        // 系统:遍历所有拥有 Position + MoveSpeed 的实体
        var matched = _world.Query(typeof(Position), typeof(MoveSpeed));
        foreach (var e in matched)
        {
            var pos = _world.GetComponent<Position>(e);
            var speed = _world.GetComponent<MoveSpeed>(e);
            pos.Value.x += speed.Value * dt;
            _world.SetComponent(e, pos);
        }
    }
}

11.4 手写版与 DOTS 版对照

能力手写版(本文)DOTS 版
实体/组件/系统✅✅
连续数组存储✅(List<T>)✅(Chunk 16KB)
组件掩码查询✅(bit mask)✅(Archetype + Chunk)
Job 并行❌✅(ScheduleParallel)
Burst 编译❌✅(机器码 + SIMD)
结构变更优化❌✅(Chunk 迁移管理)
可学习性极高中(黑盒多)

学习建议:先用这套手写 ECS 跑通,理解 90% 的概念;再上 DOTS 拿性能和生态。 这套实现里没有 Job/Burst/Chunk,但"实体+组件数组+系统遍历"的内核完全一致。


十二、适用场景与常见问题 FAQ

12.1 什么项目该上 ECS?(决策表)

项目特征建议
海量同构实体(1 万+ 单位/子弹/粒子/弹幕)✅ 强烈推荐 ECS
体素世界、大规模地形、模拟类游戏✅ 强烈推荐 ECS
有大量"每帧高频数值运算"(物理/AI/寻路)✅ 推荐 ECS + Jobs + Burst
需要服务器/客户端共享逻辑(DOTS 可跑 Headless)✅ 推荐
传统休闲小游戏(几十个对象)❌ 没必要,MonoBehaviour 够用
重度 UI、数据驱动、剧情向游戏❌ 没必要
团队没有 ECS 经验、工期很紧⚠️ 慎用(学习曲线陡)
需要频繁在 Inspector 里可视化调参⚠️ ECS 调试体验差,考虑混合架构

一句话结论:"实体数量 × 每帧计算量"是决策核心。数量上万、每帧都要算 → ECS;数量几百、逻辑简单 → MonoBehaviour。

12.2 混合架构(推荐给所有项目)

不要全项目 ECS。正确做法是分层:

┌─────────────────────────────────────────────┐
│ 表现层/流程层(OOP,MonoBehaviour)          │
│  ├─ UI、菜单、设置、存档                    │
│  ├─ 场景加载、流程控制                      │
│  └─ 与 ECS 的数据桥接(把 OOP 数据喂给 ECS)│
├─────────────────────────────────────────────┤
│ 模拟层(ECS + Jobs + Burst)                │
│  ├─ 单位、项目、子弹、敌人                  │
│  ├─ 物理、寻路、战斗结算                    │
│  └─ 大规模数值模拟                          │
└─────────────────────────────────────────────┘

桥接方式:

方向做法
OOP → ECS用 EntityManager 创建实体并写组件(第九章 SpawnSystem)
ECS → OOP用 SystemAPI.GetSingleton 或"桥接系统"把结果写回 MonoBehaviour 字段

12.3 FAQ(新手最常踩的 12 个坑)

Q1:SystemAPI.Query 报错说需要 partial 修饰符?

  • Entities 1.x 用源生成器展开 Query 的 foreach,SystemBase 类必须加 partial。加上就解决。

Q2:IComponentData 里放 string 或 class 编译能过,但运行时崩?

  • 托管字段会让组件变成"托管存储",无法 Burst/Job。改用 FixedString32Bytes / FixedString64Bytes,或把字符串放 BlobAsset/配置表。

Q3:每帧 AddComponent 导致卡顿?

  • AddComponent = 结构变更 = 换 Chunk,代价极高。改用 Enableable Component(IEnableableComponent + SetComponentEnabled)。

Q4:GetSingleton 抛异常 “Singleton not found”?

  • 单例实体还没创建(系统执行顺序不对)。用 [UpdateInGroup(typeof(InitializationSystemGroup))] 保证初始化系统先跑,或用 TryGetSingleton。

Q5:Job 里调用 EntityManager.CreateEntity 报线程错误?

  • Job 是多线程的,EntityManager 不线程安全。必须用 EntityCommandBuffer(AsParallelWriter 版本)。

Q6:Burst 编译报错 “不能使用托管类型”?

  • 检查是否用了 string、class、List<T>、Debug.Log、foreach 的托管枚举器。改用 NativeArray、Unity.Mathematics、索引循环。

Q7:NativeArray 忘了 Dispose,内存泄漏怎么办?

  • 每个 Allocator.Temp/TempJob/Persistent 都必须 Dispose;用 using 或 try/finally 保证。Profiler 会显示 Native Memory 持续增长。

Q8:实体"莫名消失"怎么排查?

  • 用 Entities Journaling(Window → Entities → Journaling)看创建/销毁调用栈。常见原因:销毁后又被引用(版本号不匹配)、ECB 播放顺序错误。

Q9:ECS 实体在 Inspector 里看不到?

  • 用 Entities Hierarchy 窗口(Window → Entities → Hierarchy),普通 Hierarchy 只显示 GameObject。

Q10:Entities Graphics 不渲染我的实体?

  • 检查三件事:① 实体是否挂了 RenderMesh(或 1.x 的 MaterialMeshInfo + RenderMeshArray);② 材质 Shader 是否 URP 兼容;③ 实体是否同时有 LocalToWorld(由 LocalTransform 自动生成,需要 TransformSystemGroup 跑起来)。

Q11:ScheduleParallel 比 Schedule 还慢?

  • 数据量太小(比如只有 100 个实体),并行调度开销 > 收益。实体少于几千时用 Schedule;上万才用 ScheduleParallel。

Q12:ECS 项目怎么做热更(HybridCLR)?

  • AOT 部分(Entities 运行时)在主包,业务系统(ISystem/SystemBase)放热更 dll;注意 AOT 泛型实例化要提前生成(IComponentData 的泛型组合),否则热更里 new 类型会崩。这个主题可以单独写一篇。

12.4 学习路线图(给不同阶段的你)

阶段1:概念
Entity/Component/System
读本文第1~3章

阶段2:跑通
装 DOTS,写第一个 MoveSystem

阶段3:掌握 API
Query/Job/Burst/ECB

阶段4:理解内存
Archetype/Chunk/SharedComponent

阶段5:实战
万级单位 + Profiler 调优

阶段6:架构
混合架构 + 迁移策略


结语

ECS 不是一套 API,而是一种看待游戏世界的方式:

  • OOP 问的是"这个对象有什么、能做什么";
  • ECS 问的是"这类数据是什么形状、要批处理多少次"。

回顾本文,你已经掌握:

  1. 为什么需要 ECS:OOP 的三个天花板(缓存不友好、继承纠缠、单线程);
  2. 核心概念与流程图:Entity 是 ID、Component 是连续数组、System 是批量逻辑、World 是容器;
  3. 内存布局的物理根源:Archetype → Chunk(16KB 正好塞进缓存)→ 顺序遍历;
  4. 完整 API:组件定义、五种创建实体方式、三种 Query、SystemBase/ISystem、Job+Burst、ECB、Singleton、Aspect;
  5. 真实代码:10000 单位批量创建 + IJobEntity 并行移动 + Entities Graphics 渲染;
  6. 设计纪律:组件要细、系统要单一职责、结构变更要克制;
  7. 迁移方法:五步法从 MonoBehaviour 迁到 ECS,以及混合架构的正确姿势;
  8. 手写实现:一套纯 C# 的 ECS,让你彻底看透内核。

给读者的三步行动建议:

  1. 先跑通:装 com.unity.entities,写一个 100 个实体的 MoveSystem,看到实体在动;
  2. 再上量:把第九章的 10000 单位代码跑起来,用 Profiler 对比 GameObject 版;
  3. 最后落地:挑项目里一个"实体多、逻辑简单"的模块(子弹/敌人海/粒子)先迁,跑稳了再扩。

ECS 的价值不在"用了它",而在"你想清楚了数据和逻辑的边界"——这份思维方式,哪怕你最后不用 DOTS,也会让你的 OOP 代码写得更干净。觉得有用记得点赞、收藏、转发,评论区聊聊你在项目里怎么用 ECS!


(本文代码基于 Unity 2022.3 LTS + Entities 1.x(DOTS)。Entities 版本迭代较快,RenderMesh 等渲染类型在小版本间有调整,遇到编译问题时请以官方手册(docs.unity3d.com → Entities)为准;所有性能数字均为原理层面的量级示意,实际以你的真机 Profiler 为准。)

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/sNameNo/article/details/170355023

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--