上一篇讲了 EasyECS 中 [ECS] 和 [NotECS] 的基本规则。
但真正放到项目里,很快就会遇到一个更实际的问题:
一个 Struct 里到底哪些字段应该放进 SoA,哪些字段应该继续保留 AoS?
这并不能单纯根据字段类型决定。
int 不一定适合 SoA,Vector3 也不一定非得拆开。
真正应该看的,是:
这个字段在实际业务中的访问方式。
项目地址
EasyECS 是 MyFramework 仓库中的独立 Unity Package。
MyFramework 主仓库:
https://github.com/ZHOURUIH/MyFramework
EasyECS UPM 地址:
https://github.com/ZHOURUIH/MyFramework.git?path=/Packages/com.zhourui.easyecs
当前版本:
EasyECS 1.1.0
先看一个很典型的角色数据
假设有这样一个运行时结构:
[ECS]
public struct RoleData
{
public int mHP;
public int mMP;
public float mSpeed;
public float mPositionX;
public float mPositionY;
public int mCamp;
public int mLevel;
public int mModelID;
public int mConfigID;
}
如果完全按照默认规则处理,那么这些字段都会进入 SoA:
HP[]
MP[]
Speed[]
PositionX[]
PositionY[]
Camp[]
Level[]
ModelID[]
ConfigID[]
这样当然能工作。
但它未必是最合理的布局。
因为这些字段的访问频率可能完全不同。
第一类:每帧都参与批处理的数据
例如:
HP
MP
Speed
PositionX
PositionY
角色移动可能每帧执行:
for (int i = 0; i < count; ++i)
{
positionX[i] += speed[i] * deltaTime;
positionY[i] += speed[i] * deltaTime;
}
战斗系统可能不断处理:
HP
MP
这类字段通常具有几个特点:
-
数据量大
-
高频访问
-
经常循环处理
-
同一字段连续访问
它们非常适合 SoA。
因此可以保留默认 [ECS] 行为。
第二类:偶尔才会使用的数据
再看:
ModelID
ConfigID
Level
它们可能只在:
创建角色
刷新模型
打开属性界面
读取配置
时使用。
假如角色移动循环根本不需要这些字段,那么为了批量处理 Position,把 ModelID 也拆成单独数组,并没有太大意义。
这时就可以考虑:
[NotECS] public int mModelID;
[NotECS] public int mConfigID;
最终变成:
[ECS]
public struct RoleData
{
public int mHP;
public int mMP;
public float mSpeed;
public float mPositionX;
public float mPositionY;
[NotECS] public int mLevel;
[NotECS] public int mModelID;
[NotECS] public int mConfigID;
}
于是热点数据连续存储,而低频数据继续保持简单的 AoS。
不要根据"字段大小"来决定
这是一个很容易出现的误区。
例如:
public int mCamp;
只有 4 字节。
于是可能觉得:
这么小,拆不拆都无所谓。
但真正需要考虑的不是它有多大,而是:
它会不会出现在热点循环里。
假如 AI 每帧都要判断:
if (camp[i] != targetCamp)
{
...
}
那么 Camp 就可能是一个非常典型的热点字段。
反过来,一个很小的:
int mConfigID;
如果整个战斗过程中都不会访问,它就可能更适合放在 [NotECS]。
所以:
字段是否适合 SoA,访问模式通常比字段大小更重要。
也不要看到引用类型就直接认为不能 ECS
实际项目里还会出现:
string
object
class
例如:
public string mName;
EasyECS 1.1.0 已经支持 Managed / Unmanaged 混合存储。
也就是说,一个 struct 中存在 Managed 字段,并不意味着整个结构必须放弃 ECS 存储。
例如:
[ECS]
public struct RoleData
{
public int mHP;
public float mSpeed;
public string mName;
}
可以让:
HP
Speed
继续按照适合它们的方式存储。
Managed 字段则由对应的 Managed Storage 管理。
所以选择 [ECS] 或 [NotECS] 时,首先应该看访问模式,而不是简单地:
值类型 → ECS
引用类型 → NotECS
EasyECS 的 Hybrid Storage 就是为了避免这种一刀切。
一个更真实的怪物数据应该怎么拆?
假设怪物运行时数据是:
public struct MonsterData
{
public int mHP;
public float mPosX;
public float mPosY;
public float mMoveSpeed;
public int mState;
public int mTargetID;
public int mMonsterConfigID;
public int mModelID;
public int mSpawnGroupID;
}
可以先问几个问题。
哪些字段每帧都要访问?
可能是:
PosX
PosY
MoveSpeed
State
TargetID
哪些字段战斗时高频变化?
HP
State
TargetID
哪些字段创建以后基本不变?
MonsterConfigID
ModelID
SpawnGroupID
那么比较合理的设计可能是:
[ECS]
public struct MonsterData
{
public int mHP;
public float mPosX;
public float mPosY;
public float mMoveSpeed;
public int mState;
public int mTargetID;
[NotECS] public int mMonsterConfigID;
[NotECS] public int mModelID;
[NotECS] public int mSpawnGroupID;
}
这就是一个很典型的 Hybrid Struct。
"不会变化"不等于"不适合 SoA"
这一点也很重要。
例如:
int mCamp;
创建以后可能永远不会变化。
但如果战斗逻辑每帧都要读取它:
if (monster.mCamp != role.mCamp)
{
...
}
它依然可能是热点字段。
所以判断标准不能是:
会不会修改
而应该是:
会不会高频访问
Read 和 Write 都会消耗内存带宽和 Cache。
只读字段一样可能非常适合 SoA。
一个字段单独访问很多次,也值得考虑 SoA
假设有 50 万个单位。
每一帧执行:
MovementSystem → Position、Speed
DamageSystem → HP
TargetSystem → Camp、TargetID
虽然每个 System 使用的字段不同,但它们都有一个特点:
每次只关心少量字段。
这种场景实际上非常适合 SoA。
因为不同系统可以分别连续扫描自己真正关心的数据:
Movement:
Position Position Position...
Damage:
HP HP HP...
Target:
Camp Camp Camp...
而不是每次把完整的大 Struct 都带进 Cache。
什么情况下反而不值得拆?
假设:
public struct RewardData
{
public int mItemID;
public int mCount;
public int mQuality;
public int mSource;
}
业务逻辑通常都是:
RewardData reward = rewards[index];
ShowItem(reward.mItemID,
reward.mCount,
reward.mQuality,
reward.mSource);
也就是每次都会把整个 RewardData 一起使用。
同时总量可能只有几十条。
那么这种数据即使能用 SoA,也没有必要强行优化。
继续使用普通 AoS 往往更简单。
所以 EasyECS 不应该变成:
项目中看见 struct 就全部加
[ECS]。
那反而违背了它的设计目的。
我的判断顺序
实际决定一个字段是否进入 SoA 时,我更倾向按照下面的顺序判断:
是不是热点数据?
↓
是不是经常批量遍历?
↓
循环是否只访问少数字段?
↓
数据规模是否足够大?
↓
Profiler 中是否真的值得优化?
前三项越明显,SoA 的价值通常越高。
如果:
数量很少
访问很低频
每次都读取完整 Struct
就没有必要为了 SoA 增加复杂度。
不要一开始就追求"完美拆分"
还有一点很重要。
刚接入 EasyECS 时,没有必要把一个拥有几十个字段的 Struct 分析半天。
完全可以先:
[ECS]
public struct RoleData
{
...
}
让它跑起来。
然后通过 Profiler 和 Benchmark 找到真实热点。
再逐渐把不需要参与 SoA 的字段改成:
[NotECS]
这和 EasyECS 整体的设计理念是一致的:
先保持代码简单,再针对热点逐步降低抽象。
而不是在没有性能数据之前就做大量复杂优化。
写在最后
EasyECS 中最值得关注的其实并不是:
[ECS]
这个 Attribute 本身。
而是它背后的一个问题:
哪些数据应该放在一起?
高频一起访问的数据,就应该尽量连续。
很少访问的数据,就不一定需要占据热点数据旁边的缓存空间。
所以实际使用 EasyECS 时,可以记住一个简单原则:
不是按照"这个字段是什么"拆,而是按照"这个字段怎么被使用"拆。
这也是从普通 AoS 走向真正 Data-Oriented Design 最关键的一步。
下一篇:
Unity 中 SoA 和 AoS 能不能同时存在?EasyECS 的 Hybrid Storage
下一篇会继续深入 EasyECS 的核心实现,看看 [ECS] 和 [NotECS] 最终是怎样在同一个 Struct 中组成 Hybrid Storage 的。