一个 [ECS] 到底生成了多少代码?EasyECS 最核心的秘密都在这里

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

:::

相关推荐
梨想橙汁1 小时前
Git 分支详解:创建切换、合并分支、冲突解决实战
前端·git
雪芽蓝域zzs1 小时前
Vue3 defineProps` / `defineEmits` 是编译器宏,不需要手动 import 导入
前端·javascript·vue.js
whyweplay1 小时前
Elpis:从Json Schema 到 页面
前端·javascript
做萤石二次开发的哈哈2 小时前
海康班班通交互一体机技能接入实战:ISAPI透传+OTAP双协议封装,Web/App/小程序教学管理应用一站生成
前端·物联网·小程序·交互·萤石开放平台·蓝海aiot一站式工作台·aiot开发
计算机魔术师2 小时前
Anthropic一次性锁死十年算力,5170亿美元买什么
前端
IT_陈寒2 小时前
Redis的订阅丢失消息?你可能忘了这个配置
前端·人工智能·后端
头茬韭菜2 小时前
第 3 篇:「Pydantic 即 Schema」—— 工具生态三层解剖
前端·chrome·ai·openmanus
恋猫de小郭2 小时前
Flutter 状态管理基准测评,一个很有趣的观点
android·前端·flutter