Unity EasyECS:并不是所有字段都适合 SoA,Unity 项目中应该怎样拆数据

上一篇讲了 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 的。

相关推荐
Android打工仔1 小时前
一次 Android 拍照后卡顿的 Perfetto 定位与优化实践
android·性能优化
刘立军1 小时前
插件化与扩展点:引导 AI 模块化插拔开发,功能解耦便于迭代
人工智能·后端·架构
YHL1 小时前
向量数据库入门(二):Embedding → 语义搜索 → RAG 日记助手
前端·架构
Rain的Java大神之路1 小时前
SkyWalking从0-1部署成功实战
java·后端·架构
NJCloud1 小时前
Openstack(一)——基础环境配置与依赖服务安装
linux·架构·openstack
围炉聊科技1 小时前
OpenAdapt:录制一次,确定性回放
人工智能·后端·架构
晓晓_za8986681 小时前
多租户 GEO 优化系统源码架构:权限隔离与数据分离实现
java·tcp/ip·spring·缓存·微服务·架构
buligbulig1 小时前
架构企业通往AI的桥梁
人工智能·架构