EasyECS 用起来最"可疑"的地方,大概就是这一行:
bash
[ECS]
public struct RoleData
{
public int mHP;
public float mSpeed;
public float mPositionX;
public float mPositionY;
[NotECS] public int mID;
}
业务代码只多了一个:
bash
[ECS]
然后项目里突然就能使用:
bash
RoleDataECSList
RoleDataRef
RoleDataECSDictionary<TKey>
Direct Column
甚至底层还能自动在:
bash
Unsafe
SafeSpan
SafeRegistry
不同实现之间切换。
看起来像是 EasyECS 做了什么运行时魔法。
实际上恰恰相反。
EasyECS 很多性能,就是因为它尽可能不在运行时做这些事。
真正繁重的工作,在编译阶段就已经被 Source Generator 做完了。
我最不想写的,就是这些代码
如果不用 Source Generator,想把 RoleData 手工改成 SoA,大概得先写:
bash
int[] mHP;
float[] mSpeed;
float[] mPositionX;
float[] mPositionY;
然后再写:
bash
Add
Set
Get
Resize
Insert
RemoveAt
RemoveAtSwapBack
Clear
Dispose
接着业务要求通过 ID 查数据,又得写 Dictionary。
想让:
bash
role.mHP -= 10;
这种代码直接改到底层,又需要 Ref。
想冲极限性能,再做 Column。
最后还要处理:
bash
string
object
Hybrid AoS
生命周期
不同 Backend
一个 RoleData 尚且如此。
项目里如果有:
bash
MonsterData
BuffData
BulletData
PlayerData
SkillData
怎么办?
复制五遍?
然后以后每增加一个字段,再同时修改五套 Storage?
这就是我最后决定把问题扔给编译器的原因。
[ECS] 并不是运行时反射
这点很重要。
EasyECS 不是启动游戏以后再:
bash
扫描 Assembly
↓
查找 [ECS]
↓
通过反射分析字段
↓
动态创建 Storage
如果这么做,至少对我来说整个方向就错了。
EasyECS 使用的是:
C# Source Generator
编译器在编译项目时看到:
bash
[ECS]
public struct RoleData
就分析:
bash
有哪些字段?
哪些是 [ECS]?
哪些是 [NotECS]?
哪些是 unmanaged?
哪些包含 managed reference?
然后直接生成普通 C# 代码。
到游戏真正运行的时候,
那些所谓的"自动生成 ECS"早就结束了。
Player 里执行的已经只是:
正常编译后的代码。
一个 Struct,实际上会被拆成几层
还是这个:
bash
[ECS]
public struct RoleData
{
public int mHP;
public float mSpeed;
public float mPositionX;
public float mPositionY;
[NotECS] public int mID;
}
Generator 首先要决定:
bash
mHP → SoA
mSpeed → SoA
mPositionX → SoA
mPositionY → SoA
mID → AoS
然后生成底层:
Storage
可以把它理解成 EasyECS 真正保存数据的地方。
概念上类似:
bash
HP Column
Speed Column
PositionX Column
PositionY Column
+
AoS Storage
业务层看到的是一个 RoleData。
Storage 看到的已经是完全不同的数据布局。
然后 Generator 要把拆开的数据重新"拼回来"
如果只生成 Storage,
业务代码最后只能这样写:
bash
hp[index]
speed[index]
positionX[index]
那跟手写 SoA 没什么区别。
所以接下来需要:
RoleDataRef
Ref 做的事情很有意思。
物理内存里:
bash
HP 在这里
Speed 在那里
ID 又在 AoS Storage
但业务代码仍然可以:
bash
RoleDataRef role = roles[i];
role.mHP -= 10;
role.mPositionX += role.mSpeed;
int id = role.mID;
看起来仍然像在操作一个完整对象。
实际上每一个属性背后都知道:
自己应该去哪块 Storage 找数据。
所以 Ref 本质上是在做:
bash
物理上拆开
↓
逻辑上重新组合
这也是 EasyECS 能同时保持 SoA 和相对自然 API 的关键。
接着才轮到 ECSList
有 Storage 和 Ref 还不够。
真实容器至少还得支持:
bash
Count
Capacity
Add
Insert
RemoveAt
RemoveAtSwapBack
Clear
Dispose
于是 Generator 再生成:
RoleDataECSList
这部分代码负责把所有 Storage 的结构变化保持同步。
例如:
bash
list.RemoveAt(10);
看起来只有一行。
生成代码实际上需要确保:
bash
HP Column 删除 index 10
Speed Column 删除 index 10
PositionX Column 删除 index 10
PositionY Column 删除 index 10
AoS Storage 删除 index 10
任何一列错位,
整个逻辑对象就已经坏了。
所以 Source Generator 帮我省掉的,不只是"重复代码"。
更重要的是:
把这种极容易因为漏改一个字段而出错的机械代码统一生成。
Dictionary 也不是另一套完全独立的实现
上一篇已经讲过 ECSDictionary:
bash
Dictionary<TKey, int>
+
Dense Keys
+
ECSList
所以 Generator 还会围绕同一个 Struct 生成:
bash
RoleDataECSDictionary<TKey>
Hash Table 负责:
bash
Key → Index
真正的数据依然落在 Dense ECS Storage 中。
这意味着:
bash
ECSList
ECSDictionary
Ref
Column
并不是四套各玩各的系统。
它们最终都围绕:
同一套生成 Storage
工作。
这一点对后期维护非常重要。
Direct Column 其实就是故意把抽象再拆掉一次
正常业务:
bash
RoleDataRef role = roles[i];
role.mHP -= 1;
已经很快。
但 50 万数据的极端循环里,我还希望进一步减少:
bash
Ref
→ Storage
→ Field
这条路径。
所以 Generator 还可以直接给热点字段生成 Column 访问能力。
最终热点循环更接近:
bash
HP Column
↓
连续扫描
也就是前面 Benchmark 里:
bash
ECS Ref 0.244 ms
ECS Direct 0.160 ms
那一层差距。
手工为几十个 Struct 写这些 API,我肯定不会愿意。
让 Generator 做就非常合适。
最麻烦的是:生成的还不能只有一套代码
如果 EasyECS 永远只支持:
bash
Allow Unsafe Code = true
Generator 会简单很多。
但实际 Unity 项目环境并不完全一样。
所以现在还要面对不同 Backend:
bash
Unsafe
SafeSpan
SafeRegistry
这意味着 Generator 不能只是:
把几个字段名填进模板。
它还需要根据当前目标生成不同的数据访问路径。
Unsafe
追求最直接的 Native Memory 和指针路径。
SafeSpan
不依赖 Unsafe 时,尽量保留连续数据访问能力。
SafeRegistry
在更加受限的环境里继续提供兼容路径。
对业务层来说仍然是:
bash
RoleDataECSList
但背后的 Storage 实现已经可以完全不同。
Source Generator 真正让我满意的地方,不是"少写代码"
最开始我用 Generator 的理由很简单:
我不想手写那么多重复代码。
后来我发现,它更大的价值其实是:
把数据布局决策提前到了编译期
例如:
bash
[ECS]
public int mHP;
[NotECS]
public int mID;
到了 Runtime 已经不需要再问:
bash
mHP 是不是 ECS 字段?
mID 应该去哪?
这个字段 managed 吗?
当前应该使用哪种访问逻辑?
Generator 早就把答案写死在生成代码里了。
这对高频循环很重要。
因为性能框架最不应该做的一件事就是:
每次访问数据时,再动态判断数据应该怎么访问。
能在编译阶段决定的事情,就不要拖到运行时。
当然,Generator 也把 Bug 变得更可怕了
手写一个 RemoveAt 写错,
可能只影响一个容器。
Generator 的模板写错:
bash
所有 [ECS] Struct
都可能一起出问题。
所以 EasyECS 后面不得不准备:
bash
Runtime Test
Generator Test
Backend Test
Diagnostic Test
否则"自动生成很多代码"只会变成:
自动生成很多 Bug。
这也是为什么我现在越来越觉得:
Source Generator 最难的不是生成代码,而是证明生成出来的代码始终正确。
写在最后
所以一个:
bash
[ECS]
背后到底发生了什么?
简单概括就是:
bash
普通 Struct
↓
编译期字段分析
↓
确定 SoA / AoS
↓
确定 Managed / Unmanaged
↓
生成 Storage
↓
生成 Ref
↓
生成 ECSList
↓
生成 ECSDictionary
↓
生成 Direct Column
↓
根据环境选择 Backend
然后到了 Runtime,
这些"魔法"全部消失。
剩下的只是正常 C# / IL2CPP 代码。
这其实就是 EasyECS Source Generator 最重要的目标:
把框架的复杂度留给编译阶段,把尽可能简单的执行路径留给 Runtime。
整个 EasyECS 系列到这里,底层核心其实已经基本讲完了。
最后还剩一篇。
我不准备继续拆 API,也不继续挖几个 ns 的 Benchmark。
最后一篇我更想回答一个从第一篇一直存在的问题:
写完 EasyECS 1.1.0,我为什么还是没把它做成完整 ECS?
也会顺便讲清楚:
bash
哪些地方适合 EasyECS
哪些地方继续用 List 更好
为什么没有 World / System / Archetype
以及 EasyECS 最终真正想解决的是什么
项目地址
EasyECS 是 MyFramework 仓库中的独立 Unity Package。
MyFramework:
bash
https://github.com/ZHOURUIH/MyFramework
EasyECS UPM:
bash
https://github.com/ZHOURUIH/MyFramework.git?path=/Packages/com.zhourui.easyecs
:::