ひとことで
大量の物体のデータを、「物体ごと」ではなく値の種類ごとの配列にまとめて持つ方法です。粒子なら、1 個ずつの {x, y, z, vx, vy, vz, 色, 寿命…} を並べる(AoS:構造体の配列)代わりに、x だけの配列、y だけの配列…を持ちます(SoA:配列の構造体)。位置の更新のように一部の値だけを大量に処理するとき、必要な値だけがメモリに隙間なく並ぶので、CPU のキャッシュがむだなく使え、SIMD による一括計算もしやすくなります。
ゲームでの使いどころ
- 粒子システム:数万〜数百万の粒子の位置・速度・寿命の更新。
- ECS(エンティティ・コンポーネント・システム):Unity DOTS、Bevy などは、同じ種類のコンポーネントを配列にまとめて並べ、システムがそれを先頭から処理する。
- 物理・群れ・AI:多数の物体の同じ処理(積分、近傍探し、視界判定)を一気に行う。
- GPU へ送るデータ:GPU のバッファも、隣のスレッドが隣のデータを読むように並べると速い。
しくみ
キャッシュライン:CPU はメモリを 1 バイトずつではなく、64 バイト程度のかたまり(キャッシュライン)で読み込みます。一度読み込んだかたまりのデータは速く使えますが、使わないデータが混ざっていると、読み込んだ分がむだになります。
- AoS:粒子 1 個 = 12 個の値(48 バイト)なら、位置の更新に使う 6 個(位置と速度)を読むために、使わない 6 個(色や寿命)も一緒に読み込みます。読み込んだデータの半分がむだです。
- SoA:
xの配列にはxだけが並ぶので、読み込んだかたまりはすべて使います。先頭から順に読むので、CPU の先読み(プリフェッチ)もよく効きます。
SIMD:CPU には、1 命令で 4〜16 個の値を同時に計算する命令があります。SoA なら、x[i..i+8] をまとめて読み、vx[i..i+8] と一度に足し算できます。AoS では、値が飛び飛びに並んでいるので、まとめて読むのが困難です。
JavaScript のオブジェクトの配列は、さらに不利です。オブジェクトはメモリのあちこちに置かれ、配列には参照(ポインタ)しか入っていないので、1 個ごとに離れた場所を読みに行くことになります。
実装のポイント
- 一緒に使う値はまとめてもよい:いつも一緒に使う値(x, y, z)は、
[x y z][x y z]…と小さくまとめる(AoSoA、または xyz だけの配列)のも有効です。処理ごとに、何を一緒に読むかで並べ方を決めます。 - 削除は末尾と入れ替え:途中の要素を消すときは、すべての配列で最後の要素と入れ替えて数を 1 減らすと、隙間なく詰めたまま保てます。
- 添え字で扱う:物体をオブジェクトではなく番号(エンティティ ID)で扱い、各配列の同じ番号がその物体のデータ、という形にします。
- 測ってから決める:データの量が少ない場合や、物体ごとに複雑な処理をする場合は、AoS の方が書きやすく、速さの差も小さいことがあります。
つまずきやすい点
- 1 つの物体の値を全部使う処理は遅くなる:物体 1 個のすべての値を読む処理(保存やデバッグ表示)では、SoA は多くの配列を飛び飛びに読むことになります。
- 配列の数がそろわない:値の種類を増やしたとき、1 つの配列だけ大きさを変え忘れると、範囲外を読み書きします。まとめて確保する仕組みを作ります。
- コードが読みにくくなる:
particle.positionのように書けなくなるので、配列をまとめた構造(ビュー)を用意して読みやすさを保ちます。
デモで試すこと
- 50 万個の粒子の位置と速度を、毎フレーム実際に更新して時間を測っています(表示しているのは先頭の 3 万個)。右下は、そのときの実測時間のグラフです。
- 「AoS:粒子ごとのオブジェクト」と「SoA:値の種類ごとの配列」を切り替えて、更新にかかる時間を比べてください。同じ計算でも、SoA の方が速くなります。
- 右下のメモリの並びの図で、緑が更新に使う値、灰色が使わない値、黄色の線がキャッシュラインの区切りです。AoS では、使う値と使わない値が交互に並んでいます。
- 「粒子の数」を 100 万にすると、差がより大きく出ます(結果はお使いの環境の CPU によって変わります)。