面向数据
DOD, Data-Oriented Design, 面向数据编程. 或许在面向对象的时候我们主要考虑的是这个“世界”如何被我们抽象, 被我们理解. 但是 DOD 的思想让我们更偏向去考虑在机器当中, “每一帧”的数据应当如何组织, 如何排列, 可以使得其运行效率更高.
例子
依旧例子, 最经典的面向对象的 Game Object 设计 —— 一个 GO 身下有 Transform 组件, 我们派生了很多类似子弹, 玩家角色, NPC 等. 所以在每一帧我们需要遍历 GOs, 获得其 Transform 组件, 然后进行新的位置的计算并且刷入 Transform 组件.
然而实际上在我们更改 Transform 的时候, 各种 GO 的数据其实是不太需要的, 比如什么 Mesh, Material, 以及其他的什么更特化的字段.
但是如果直接访问存储 GOs 的数据 —— 直接操作对象本身, 不需要的数据其实也可能在塞进 Cache Line 被 CPU 读取;
假设使用指针间接访问要的字段(比如std::vector<std::unique_ptr<GameObject>> objects; 这么存储), 多了指针寻址的操作, 而且内存局部性无法保证.
而在 DOD 中, 我们可能会
struct Position
{
float x, y, z;
};
struct Velocity
{
float x, y, z;
};
std::vector<Position> positions;
std::vector<Velocity> velocities;然后遍历这两个向量即可. 很好, 因为一方面它们分别在底层上是连续存储的, 而且在读取的时候也不会引入其他额外的数据
DOD 及其优势
考虑数据本身, 我们怎么存储, 每次取用的时候需要什么数据, 要执行什么操作. 然后我们根据自己的访问模式来进行存储布局的调整.
比如上述例子中, 其实 Position 由于 x, y, z 在每次操作中都需要出现, 所以打包在一起, 就不是完全意义的 SoA 或者 AoS.
首先, 对于读取一次的 Cache Line 来说, 里面可能包含更多的可用数据, 同时硬件预测也可能会读取之后的数据进缓, 使得访问加速; 同时, 编译器也更容易借助 SIMD 进行指令优化. 而且这样也比较适合多线程并行进行运算 —— 比如现在需要计算所有 Transform, 同时也需要计算角色现在受到的伤害, 那么对于一个角色来说, 两个系统同时访问了它, 这就使得依赖关系难以说明 —— 「a 现在同时有两个系统用到的了 Player, A 系统和 B 系统会不会访问到相同的字段导致数据竞态 a 好难想」 这种感觉, 因为二者都依赖了 Player. 不过如果拆成不同的存储位置, 不再以 Player 做一次聚合, 然后分别传输给两个系统, 就会使得依赖关系明确 —— 移动就是依赖 Transform; 伤害就是依赖 Health; 所以丢给多线程操作也会理直气壮一些 —— 因为读写依赖关系非常明确, 方便构造