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 的三个天花板
- 二、ECS 核心概念与架构流程图
- 三、OOP 与 ECS 的内存布局对比
- 四、Unity DOTS 环境搭建与组件定义
- 五、System 与 Query:ECS 的"大脑"
- 六、Job System + Burst:把多核榨干
- 七、深入内存布局:Archetype、Chunk、SharedComponent、DynamicBuffer、BlobAsset
- 八、EntityCommandBuffer、Singleton 与 Aspect
- 九、完整实战:10000 个单位同屏移动与渲染
- 十、ECS 设计原则与从 OOP 迁移实战
- 十一、手写一个不依赖 DOTS 的 ECS
- 十二、适用场景与常见问题 FAQ
- 结语
一、为什么需要 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(世界) | 所有实体+组件的容器,一个游戏可以有多个 World | World(World.DefaultGameObjectInjectionWorld) |
关键点:Entity 本身不是对象,它只是一个"句柄/索引"。数据存在组件数组里,Entity 只是"第几行"的编号。这就是 ECS 和 OOP 最本质的区别。
2.2 整体架构流程图(Mermaid)
这张图要记住三件事:
- Entity 是索引,指向三条数组中同一"行"的数据;
- 每个组件按类型排成一条连续数组(这是性能的关键);
- 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 每帧的运行流程
流程要点:
- 系统顺序是确定的(
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,面向数据技术栈) 不是一个包,而是四个技术的组合:
| 技术 | 职责 | 少了它会怎样 |
|---|---|---|
| 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 差异很大(
TranslationvsLocalTransform、ComponentSystemvsSystemBase),请按 1.x 版本对齐。
步骤 1:打开 Package Manager,添加以下包
| 包名 | 用途 | 必需 |
|---|---|---|
com.unity.entities | ECS 核心 | ✅ |
com.unity.entities.graphics | ECS 批量渲染 | 需要渲染时 |
com.unity.rendering.hybrid | 旧版渲染(1.x 已并入 graphics) | 视版本 |
com.unity.physics | ECS 物理 | 需要物理时 |
com.unity.collections | NativeArray 等(通常随 entities 自动装) | 自动 |
添加方式:
Window → Package Manager;- 点左上角
+→ Add package by name; - 输入
com.unity.entities,等待安装; - 装完后 Project 里会出现
Unity.Entities程序集。
步骤 2:项目设置(关键,不做会报错)
Edit → Project Settings → Player → Other Settings:
| 设置项 | 值 | 原因 |
|---|---|---|
| Scripting Backend | IL2CPP | Burst 只支持 IL2CPP(编辑器里也能跑,但真机必须) |
| Api Compatibility Level | .NET Standard 2.1 / .NET Framework | 建议 .NET Standard |
| Allow ‘unsafe’ Code | 视需求 | 一般不需要 |
步骤 3:Entities Graphics(渲染)额外设置
如果用 URP:
- 安装
com.unity.render-pipelines.universal; - 在 URP Asset 里,
Rendering → Entities Graphics会被自动启用; - 材质的 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 性能的根本 |
| 无 GC | struct 不产生托管堆分配,运行时不触发 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(美术/策划最爱的编辑器工作流)
- 场景里放一个 SubScene(
GameObject → Entities → SubScene); - 在 SubScene 里像平时一样摆 GameObject、挂 MonoBehaviour 的"Authoring"脚本;
- 给 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 + 闭包,写法灵活,可方便操作托管数据 | 逻辑复杂、需要和托管代码交互 |
ISystem | struct,完全 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 顺序(部分):
| 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();
}
}
逐项解释:
| 元素 | 说明 |
|---|---|
IJobEntity | 1.x 的"实体遍历 Job",Execute 自动对每个匹配实体调用 |
ref LocalTransform | 可读写组件(相当于 RefRW) |
in MoveSpeed | 只读组件(相当于 RefRO) |
Dependency | SystemBase 的内置 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);
}
}
ScheduleParallelvsSchedule:
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.Mathematics | float3、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 → 静态装饰
关键规则:
- 实体一旦创建,它的 Archetype 就确定了(拥有哪些组件);
AddComponent/RemoveComponent会改变 Archetype → 实体必须从旧 Chunk 搬到新 Chunk(这叫 结构变更 Structural Change,很贵!);- 因此,高频的"加/减组件"操作要用 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 数组对比:
| DynamicBuffer | NativeArray 字段 | |
|---|---|---|
| 存储位置 | 在 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 内存布局全景图
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 这一章的核心结论(背下来)
- Archetype = 组件组合 = 实体类型;加/减组件 = 结构变更 = 昂贵;
- Chunk = 16KB 内存块 = 缓存友好的根源 = 并行的最小单位;
- SharedComponent = 按值分组 = 只存一份 = 不能频繁改 = 不能进 Burst;
- DynamicBuffer = 每实体的变长数组,适合路径/背包;
- 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 的执行时机:
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 数据流总览图(本章总结)
九、完整实战: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 的渲染组件在不同小版本里有差异(
RenderMeshvsRenderMeshArray+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 完整执行流程图
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 Call | Entities Graphics 批量合批 |
| 万级可扩展性 | 3 万以上明显掉帧 | 数十万仍可接受(视逻辑复杂度) |
务必自己实测:把上面两套代码都跑起来,用 Profiler 对比。只有你亲眼看到的数字才是真的。任何博客里的"快 100 倍"都不该被直接采信。
9.7 调试 ECS 的必备工具
| 工具 | 打开方式 | 用途 |
|---|---|---|
| Entities Hierarchy | Window → Entities → Hierarchy | 查看运行时所有实体、组件、Chunk |
| Systems Window | Window → Entities → Systems | 看所有 System 的耗时、执行顺序 |
| Entities Journaling | Window → Entities → Journaling | 记录结构变更历史(谁创建/销毁了什么) |
| Profiler | 常规 Profiler | System 耗时、GC、Job 时间线 |
| Burst Inspector | Window → 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
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 架构设计
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 学习路线图(给不同阶段的你)
结语
ECS 不是一套 API,而是一种看待游戏世界的方式:
- OOP 问的是"这个对象有什么、能做什么";
- ECS 问的是"这类数据是什么形状、要批处理多少次"。
回顾本文,你已经掌握:
- 为什么需要 ECS:OOP 的三个天花板(缓存不友好、继承纠缠、单线程);
- 核心概念与流程图:Entity 是 ID、Component 是连续数组、System 是批量逻辑、World 是容器;
- 内存布局的物理根源:Archetype → Chunk(16KB 正好塞进缓存)→ 顺序遍历;
- 完整 API:组件定义、五种创建实体方式、三种 Query、SystemBase/ISystem、Job+Burst、ECB、Singleton、Aspect;
- 真实代码:10000 单位批量创建 + IJobEntity 并行移动 + Entities Graphics 渲染;
- 设计纪律:组件要细、系统要单一职责、结构变更要克制;
- 迁移方法:五步法从 MonoBehaviour 迁到 ECS,以及混合架构的正确姿势;
- 手写实现:一套纯 C# 的 ECS,让你彻底看透内核。
给读者的三步行动建议:
- 先跑通:装
com.unity.entities,写一个 100 个实体的MoveSystem,看到实体在动; - 再上量:把第九章的 10000 单位代码跑起来,用 Profiler 对比 GameObject 版;
- 最后落地:挑项目里一个"实体多、逻辑简单"的模块(子弹/敌人海/粒子)先迁,跑稳了再扩。
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



