1. 为什么 2022.3 LTS + Entities 1.4 值得你从手动创建开始
如果你和我一样,在 Unity 2022.3.62 上装好 Entities 1.4,却被官方示例里 SubScene、Authoring、Baker 三件套绕得头晕,那这篇文章就是写给你看的。我会绕开所有自动烘焙的捷径,用 EntityManager 亲手把一个 Entity 从无到有建出来,挂上自定义组件,再写一个 ISystem 让它自己动起来。作为 ECS 的入门路径,手动创建能帮你把 Archetype、Chunk、System 查询这些概念从文档里的名词变成脑子里真正有画面的东西。适合刚接触 DOTS/ECS、准备把项目切到这套架构、或者单纯想知道 Entities 1.4 底层发生了什么的开发者阅读。
后边你会看到,这套手动工作流不只是教学用的玩具。程序化生成地块、动态加载物品、做客户端服务器共享逻辑的工具链,很多生产级 DOTS 项目底层就是在这样手动控制的 World 里运行的。自动烘焙和手动创建之间没有高低之分,但如果你想真正理解 ECS,手动走一遍是绕不开的功课。
1.1 这个版本组合在 Unity 生态里的准确位置
先给版本组合定个位。Unity 2022.3 是长期支持版本,修复了大量编辑器层面的稳定性问题,而 Entities 1.4 是 1.x 这条产品线上的稳定版本,API 已经不像 0.x 时代那样每个小版本都能把你现有代码炸一遍。选 2022.3.62 这个补丁版的好处是,它跟 Entities 1.4 依赖的 Burst、Jobs、Collections 包兼容性都经过了一轮轮补丁验证,不会出现你在 2021 版本上装新 Entities 那种"包管理器允许装,编译却报一堆奇怪的错"的尴尬情况。
这套组合解决的核心问题,是把原来 GameObject + MonoBehaviour 那种零散的对象模型,换成数据紧凑、缓存友好的 ECS 存储模型。同一个 Archetype 的实体在内存里连续排列,System 遍历时 CPU 缓存命中率大幅提升,再加上 Burst 编译,性能上限比传统方案高很多。手动创建的方式,则是让你在没有任何自动魔法的情况下,直接操作这套存储模型。
1.2 手动创建与 Authoring 自动烘焙的分水岭
SubScene 里的 Authoring 工作流是这样的:你先写一个 MonoBehaviour 组件,再写一个 Baker,把 MonoBehaviour 的数据转成 ECS 的 IComponentData;场景烘焙时,Unity 在后台把整个 GameObject 层级拆成一批 Entity,连同组件数据一起存进烘焙后的场景文件里。这套机制的好处是美术和策划可以继续用熟悉的 GameObject 工作流,坏处是很多人用了一年 SubScene 也没搞明白实体到底存在哪个 World、数据在哪个 Chunk。
手动创建则完全相反。代码执行到 EntityManager.CreateEntity() 的那一刻,你明确知道实体被放进了哪个 World、用了哪个 Archetype、数据写进了哪个 Chunk。两条路径创建的实体最终都在同一个默认 World 里,System 不会区分你是手动建的还是烘焙出来的,只要组件类型匹配,查询逻辑一视同仁。
手动创建的真正价值在场景需要动态变化时特别明显。比如运行时随机生成敌人、根据玩家行为动态搭建设施、或者做编辑器工具批量生成测试数据,这些场景下 Authoring 工作流反而碍手碍脚,直接操作 EntityManager 是最干净的做法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 安装环节:三个可能让你卡一下午的配置点
2.1 Package 安装的正确顺序和验证方法
安装本身不算复杂:打开 Window > Package Manager,左上角 Package 下拉框选择 Unity Registry,搜索 Entities,版本选择 1.4.x,点 Install。Unity 会自动拉取依赖项,包括 com.unity.burst、com.unity.collections、com.unity.jobs、com.unity.mathematics 这几个核心包。
有个细节容易被忽略:如果你项目里之前装过 DOTS 相关的早期预览包,比如 Entities 0.51 或者手动改过 manifest.json 写过某个测试版本,最好先在 Package Manager 里把旧包全部移除,再装 1.4。否则依赖解析器会把几个版本的 Entities 同时留在工程里,编译时你会看到一堆"类型存在于多个程序集"的报错,解决起来比重新安装麻烦得多。
安装完成后,可以打开 Packages/manifest.json 确认版本号。正常情况应该看到 "com.unity.entities": "1.4.3" 这样的条目。注意不要写成通过 git url 直接引用某个开发分支,那样依赖关系很难锁死,后续同事拉代码容易翻车。
2.2 项目设置里需要动手改的三项
装完包后,有三处配置我建议你顺手确认一遍,它们都属于"默认值看起来能用,实际影响运行行为"的类型。
第一项是 Scripting Runtime Version。打开 Project Settings > Player > Other Settings > Configuration,确认 Scripting Runtime Version 是 .NET Standard 2.1。Entities 1.4 的编译目标假定你在这个运行级别上,如果项目还停留在 .NET Framework 版本,某些 API 会直接编译失败,而且错误信息不会告诉你具体是哪个包不满。
第二项是 Scripting Backend。开发阶段用 Mono 没问题,调试体验更直接;发布到目标平台时建议切到 IL2CPP,配合 Burst 能获得更好的优化效果。注意:切换 Backend 后首次运行会自动触发 IL2CPP 编译,耗时较长,别以为卡死了。
第三项是 Jobs 调试相关选项。Project Settings 里 Jobs 一栏的 Safety Checks 建议保持开启,至少在入门阶段别关。它会在越界访问、并发修改组件数据时给出明确报错,帮你尽早发现问题。等你对自己写的代码有把握了,再考虑为了极致性能关闭。
2.3 环境相关的两个真实事故
第一个事故是我自己也踩过的:项目从 Unity 2021 升级上来,工程里留着一套老 DOTS 包,包括 com.unity.dots.editor 和 Entities 0.x。安装 Entities 1.4 后,Package Manager 没有自动卸载旧包,结果编译时出现大量"命名空间 Unity.Entities 中不存在类型 X"的报错。排查了很久才发现是两套版本在打架。解决办法很粗暴:把所有 DOTS 相关包从 manifest.json 里删干净,重新 Install 一次。
第二个事故跟 Burst 有关。同事在一台配置较老的机器上打开项目,装完 Entities 后编辑器偶尔崩溃,日志指向 Burst 编译器。最后发现是显卡驱动版本太旧,Updated Unity 到 2022.3.62 后依然有问题,更新驱动才解决。如果你遇到类似情况,优先检查 Burst 版本和驱动,而不是怀疑 Entities 本身。
3. 从 EntityManager 开始手动组装一个实体
3.1 先造一个纯空壳 Entity 并确认它活着
打开场景,随便挂一个 MonoBehaviour 脚本,我们在 Start 里写这个世界的第一行代码:
csharp复制using Unity.Entities;
using UnityEngine;
public class ManualSpawner : MonoBehaviour
{
void Start()
{
var world = World.DefaultGameObjectInjectionWorld;
var entityManager = world.EntityManager;
Entity empty = entityManager.CreateEntity();
entityManager.SetName(empty, "EmptyEntity");
Debug.Log($"Entity 存在? {entityManager.Exists(empty)}");
}
}
World.DefaultGameObjectInjectionWorld 是 Unity 在进入 Play Mode 时自动创建的默认 World,所有没有手动指定 World 的 System 都在里面运行。CreateEntity() 不带参数时创建一个没有任何组件的实体,它是合法的,只是没有数据而已。
很多人第一次看这段代码会疑惑:一个空壳实体有什么用?它的作用在于让你理解 ECS 的基本单位——Entity 只是一个 ID,一个不包含数据的句柄。数据全部存在于组件里,组件存在于 Chunk 里,Entity 只是把这些组件串起来的引子。SetName 不是组件,它是编辑器辅助信息,方便你在 Entities 窗口里识别实体。
3.2 定义 IComponentData 并声明 Archetype
空壳实体没有业务价值,下一步给它挂上数据。先定义一个组件类型:
csharp复制public struct MoveSpeed : IComponentData
{
public float Value;
}
IComponentData 是 ECS 中最基础的组件接口,它只描述数据,不含方法,可以视为一个纯数据结构。别写属性、别写构造函数,就老老实实放字段。ECS 需要的是连续内存布局,属性访问器会引入额外逻辑,虽然在编辑器里能用,但在 Burst 和 Job 环境中会限制优化。
接着在 Start 里创建 Archetype:
csharp复制var archetype = entityManager.CreateArchetype(
typeof(LocalTransform),
typeof(MoveSpeed)
);
Entity entity = entityManager.CreateEntity(archetype);
LocalTransform 是 Entities 1.x 内置的变换组件,包含 Position、Rotation、Scale 三个字段。Archetype 是组件类型的组合,你可以把它理解成一张表头:这张表包含哪几列数据,决定了实体的存储位置。同一个 Archetype 的所有实体会被分配到同一种 Chunk 内存块里,按数组连续排列,这就是 ECS 性能优势的基础。
3.3 给实体写入初值和改名,把调试成本降下来
实体创建后,组件数组里是默认值。你需要显式写入初始数据:
csharp复制entityManager.SetComponentData(entity, LocalTransform.FromPosition(new float3(1f, 2f, 3f)));
entityManager.SetComponentData(entity, new MoveSpeed { Value = 2f });
entityManager.SetName(entity, "ManualPlayer");
这里注意:SetComponentData 要求实体当前的 Archetype 里已经包含这个组件类型,否则会抛出异常。顺序是先确定 Archetype,再写数据,不要反过来。如果想在创建后追加组件,可以用 AddComponent<T>(entity),但这会改变实体的 Archetype,导致实体从原来的 Chunk 搬到另一个 Chunk,开销比直接创建时就带全组件大得多。入门阶段建议先想清楚实体需要哪些组件,再创建 Archetype。
每次调试时给实体起名字是个好习惯。尤其实体数量变多之后,在 Entities Hierarchy 里面对一串 Entity:12345 根本没法干活,有名字定位快很多。
3.4 批量创建时,为什么 Archetype 必须先定
单实体看起来很简单,一旦你要生成上千个敌人,写法就需要注意了。推荐这样:
csharp复制var entities = new NativeArray<Entity>(1000, Allocator.Temp);
entityManager.CreateEntity(archetype, entities);
for (int i = 0; i < entities.Length; i++)
{
entityManager.SetComponentData(entities[i], LocalTransform.FromPosition(new float3(i, 0f, 0f)));
entityManager.SetComponentData(entities[i], new MoveSpeed { Value = 1f + i * 0.01f });
}
一次性创建 1000 个实体,Unity 会在对应 Archetype 的 Chunk 里连续分配内存,效率极高。反过来说,如果写成"先创建 1000 个空实体,再逐个 AddComponent",每一次 AddComponent 都触发 Archetype 变化和 Chunk 迁移。实体多的时候,这个开销足以让编辑器卡住几秒。
这里的核心原理是:Archetype 就是 ECS 世界的物质分类,实体在创建那一刻就决定了它的存储位置。同样组件组合的实体越多,数据越紧凑,System 遍历性能越好。手动创建工作流天然强迫你先规划组件组合,这反而是好事。
4. System 侧:让手动创建的实体真正动起来
4.1 SystemBase 和 ISystem,新手到底选哪个
实体创建出来了,数据也有了,但没人处理它。现在需要一个 System 来产生行为。Entities 1.4 提供两种系统写法:SystemBase 和 ISystem。我直接给结论:新项目用 ISystem,除非你有特殊需求。
| 维度 | SystemBase | ISystem |
|---|---|---|
| 类型 | class(引用类型) | struct(值类型) |
| GC 压力 | 有,系统实例本身是托管对象 | 无,更容易配合 Burst |
| 生命周期 | 类似 MonoBehaviour,OnCreate/OnDestroy | 同样有 OnCreate/OnUpdate/OnDestroy |
| 时间访问 | this.Time.DeltaTime | SystemAPI.Time.DeltaTime |
| 推荐场景 | 老代码迁移、需要保存引用类型状态 | 新代码、性能敏感逻辑 |
SystemBase 更接近传统 MonoBehaviour 思路,生命周期方法用起来很顺手,保存私有字段也比较自然。但它本质是 class,在 World 中存在时会带来托管分配,如果你追求极致性能,ISystem 是更正确的选择。
4.2 一个最小 ISystem 的完整代码
我们把之前创建的实体交给一个 ISystem 处理:每帧按照 MoveSpeed 的速度沿 X 轴移动。
csharp复制using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
[BurstCompile]
public partial struct MoveBySpeedSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float deltaTime = SystemAPI.Time.DeltaTime;
foreach (var (speed, transform) in SystemAPI.Query<RefRO<MoveSpeed>, RefRW<LocalTransform>>())
{
transform.ValueRW.Position += new float3(speed.ValueRO.Value, 0f, 0f) * deltaTime;
}
}
}
SystemAPI.Query<RefRO<MoveSpeed>, RefRW<LocalTransform>>() 是 Entities 1.x 的查询语法,意思是从当前 World 中找到所有同时拥有 MoveSpeed 和 LocalTransform 的实体。RefRO 表示只读引用,RefRW 表示可写引用,这个读写标记不仅是你给编译器的意图声明,也是 DOTS 安全检查的依据。
循环体里的逻辑很简单:拿速度乘以帧间隔时间,加到位置上去。注意 speed.ValueRO.Value 读取的是只读引用,而 transform.ValueRW.Position 修改的是可写引用。ISystem 是值类型,不需要 new,Unity 的默认 World 会自动管理它的生命周期。
4.3 System 自动发现机制:为什么你没有注册它也能跑
很容易忽略一个事实:你并没有在任何地方注册这个 System,它怎么就自动运行了?这里涉及 Entities 1.4 的系统发现机制。
进入 Play Mode 时,默认 World 会扫描所有程序集,找出实现了 ISystem 或继承 SystemBase 的类,自动实例化并挂到合适的 SystemGroup 下面。默认的三大组是 InitializationSystemGroup、SimulationSystemGroup、PresentationSystemGroup。如果你的系统没有用 [UpdateInGroup] 指定上级,它会默认进入 SimulationSystemGroup,也就是游戏逻辑更新阶段。
所以流程是这样的:你挂的 MonoBehaviour 在 Start 里创建实体,默认 World 里的 MoveBySpeedSystem 在下一帧的 Simulation 阶段开始处理它。整个过程没有任何人写"注册代码",全靠 System 自动发现机制。这跟传统 MonoBehaviour 必须在场景里有实例才能运行完全不同。
[UpdateInGroup(typeof(SimulationSystemGroup))] 这个特性可以手动指定系统归属,也可以用 [UpdateBefore]、[UpdateAfter] 控制同一组内多个系统的执行顺序。入门阶段先用默认分组即可,等需要多系统协作时再研究顺序控制。
5. 验证你的手动成果:别再用 Debug.Log 一条条看
5.1 Entities Hierarchy 窗口的使用方法
实体和 System 跑起来之后,怎么确认你的手动创建真的生效了?强烈建议打开 Window > Entities > Hierarchy 窗口,这是 ECS 世界的观察入口。
窗口打开后,左侧是所有 World,默认有一个名为 Default World 的节点。展开它,可以看到三个 SystemGroup,再展开 SimulationSystemGroup,能找到你的 MoveBySpeedSystem。选中它,右侧会列出这个 System 查询到的所有 Archetype、Chunk 数量、实体数量。
窗口底部还有一个搜索框。输入你 SetName 时起的实体名,比如 ManualPlayer,就能精确定位到实体,查看它的所有组件字段当前值。这比传统 Unity Hierarchy 面板更接近 ECS 的真实存储结构,因为它直接显示 Chunk 和 Archetype 层面的信息。刚开始可能会觉得信息量过大,但用顺手后,排查实体数据问题比 Debug.Log 快十倍。
5.2 用代码检查 Entity 状态的三板斧
编辑器窗口能看到的,运行时不一定能实时看到。你需要在代码里做断言或日志输出时,常用的检查 API 就三个:
csharp复制// 1. 实体是否还活着
bool exists = entityManager.Exists(entity);
// 2. 是否拥有指定组件
bool hasSpeed = entityManager.HasComponent<MoveSpeed>(entity);
// 3. 读取组件数据
if (hasSpeed)
{
MoveSpeed speed = entityManager.GetComponentData<MoveSpeed>(entity);
Debug.Log($"MoveSpeed = {speed.Value}");
}
这三个 API 是手动工作流里最常用的验证工具。特别是实体被 System 移动后,你想确认位置是否真的变了,就 GetComponentData 读 LocalTransform 的 Position。注意通过 entityManager 读数据是即时读取,数据一定是最新的,不会因为 Job 调度而延迟。
还有一个容易踩的坑:不要从 MonoBehaviour 的 Update 里高频调用这些 API 去刷日志,那会拖垮性能。需要观察数据时用 Entities Hierarchy 窗口,或者在特定条件下打一次日志就行。
5.3 调试和 Burst 的相处方式
当你给 System 加上 [BurstCompile] 后,编辑器的调试体验会发生变化。默认情况下,Burst 在 Editor 中会启用编译优化,你很难在循环体内打断点,调试器经常显示"无法命中断点"。
解决方式有两个:一是打开 Window > Analysis > Jobs,勾选或取消 Burst Compilation 的启用状态;二是临时把 System 上的 [BurstCompile] 注释掉,等逻辑调通再恢复。我个人习惯是先把 Burst 关掉,把 System 逻辑调通、确认数据无误,再把注释恢复,开 Burst 跑一轮性能验证。
注意一个细节:ECS 的组件存储在非托管内存中,调试器看到的字段值有时候是展开后的原始内存。比如 LocalTransform 内部是 float4 结构,你在 Watch 窗口里看到 4 个浮点数组,其中前三个是 Position,最后一个通常是 Scale 相关的分量,不要被吓到。
6. 从入门到能落地的几个延伸点
6.1 迭代中创建/销毁 Entity:这时候该用 ECB
你的 System 跑起来了,接下来有个很现实的问题:如果 System 在遍历所有实体的同时,需要创建新实体或者销毁某个实体,能直接调用 EntityManager 吗?
答案是不能,或者说最好不要。直接在遍历 Chunk 时创建或销毁实体,会改变正在迭代的数据结构,轻则遍历结果错乱,重则编辑器直接崩掉。正确做法是用 EntityCommandBuffer(ECB),把创建、销毁、加组件这些操作先记录下来,等当前帧的 SystemGroup 统一执行。
csharp复制public partial struct SpawnerSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var ecb = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>()
.CreateCommandBuffer(state.WorldUnmanaged);
if (SystemAPI.Time.ElapsedTime > 1f)
{
var newEntity = ecb.CreateEntity();
ecb.AddComponent(newEntity, LocalTransform.FromPosition(new float3(0f, 0f, 0f)));
ecb.AddComponent(newEntity, new MoveSpeed { Value = 3f });
}
}
}
ECB 的核心思想是命令缓冲:不立刻执行,先记录,到安全时机再批量执行。这在多线程 Job 里尤其重要,因为多个 Job 并行修改同一个 World 是不安全的,但并行往各自的 ECB 里记录命令是安全的。入门阶段,你只需要记住这条规则即可:System 迭代里改结构用 ECB,MonoBehaviour 里改结构用 EntityManager。
6.2 自定义 World 先别碰,除非你能回答这3个问题
默认 World 用了一段时间后,你可能听说过多 World 架构,比如服务端和客户端各跑一个 World,或者把网络逻辑和渲染逻辑分开。这确实是大项目常见方案,但入门阶段我建议别碰。
如果你真要自定义 World,先回答自己三个问题:第一,默认 World 里用 SystemGroup 过滤是否真的无法满足你的架构需求?第二,多 World 带来的额外 System 管理、跨 World 通信、生命周期管理成本,团队能长期承受吗?第三,你是否已经有把握手动初始化一个 World 里的所有 SystemGroup 和依赖关系?
自定义 World 的坑在于:Unity 不会自动帮你管理新 World 里的 System,你需要手动创建、手动绑定到 SystemGroup、手动销毁,任何一步遗漏都会出现"逻辑没跑"的诡异问题。默认 World 在 99% 的入门项目里都够用,等架构需求明确再扩张不迟。
6.3 手动工作流怎么往生产级演进
手动创建实体只是起点。实际项目里,你会逐渐需要这些东西:用 Prefab 组件标记某个实体为预制体,然后通过 Instantiate 批量复用;用 Aspect 把频繁出现的组件组合封装成一个可复用的接口,避免每个 System 里反复写长串查询;用 EnableableComponent 来开关单个实体上的组件,而不是销毁重建。
举个例子,敌人系统里每个敌人都有 Health、MoveSpeed、LocalTransform,你就可以定义 EnemyAspect 把这些组件封装起来,System 代码里一个条件就能拿到所有需要的组件引用。这套封装跟手动创建不冲突,反而让 EntityManager 的创建逻辑更清晰。
后续学习路线也很明确:先吃透 Entities 1.4 的手动工作流,再研究 Burst 的编译特性和 Job 的多线程调度,最后回归到 SubScene 和 Authoring。那时候你再回头看 Baker,会发现它做的事情其实跟你手动写的 SetComponentData 一脉相承,只是自动化了。
最后说一个我自己的体会。每次带新人入门 ECS,我都会让他先手动创建实体,而不是直接拖 SubScene。原因很简单:一旦你亲手写过 EntityManager.CreateEntity、SetComponentData、SystemAPI.Query 这条完整链路,之后看任何官方示例都会顺很多。手动创建并不是绕远路,反而是把 DOTS 核心的"组件即数据、系统即逻辑"这套思维真正装进脑子里的最短路线。祝你在 DOTS 路上少踩坑,多享受这套架构带来的性能红利。
