【ゲーム制作】GPUインスタンシングとは?|パーティクルを例に仕組みを解説

GPUインスタンシングでパーティクルを大量描画するイメージ ゲーム制作

今回は、ゲーム制作でよく出てくる「GPUインスタンシングとは何か」について、パーティクルを例に解説していきます。

「パーティクルを大量に出すと急に重くなる」
「同じ絵を何千個も描くのに、いい方法はないの?」
「インスタンシングって聞くけど、何がどう速くなるの?」

こんな疑問はありませんか?

GPUインスタンシングは、ざっくり言うと「同じ形のメッシュを、たった1回の描画命令でまとめて大量に描く仕組み」です。パーティクル(火花・煙・弾幕)のように「同じ四角を位置だけ変えて何千個も出す」処理と、とても相性がいい技術です。

この記事を読み終えると、あなたはインスタンシングが速い理由・仕組み・DirectXでの実装の流れ・初心者がハマりやすいミスをしっかり理解できると思いますので、ぜひ最後まで読んでいただけると嬉しいです。

なぜパーティクルは重くなるのか

まず、素直に書くとどうなるかを見てみましょう。パーティクルを1個ずつ描くと、こうなります。

// パーティクル1個ずつ描く(重い書き方)
for (auto& p : particles) {          // 10000個なら…
    UpdateConstantBuffer(p.pos);      // 定数バッファ更新
    context->DrawIndexed(6, 0, 0);    // 四角1枚 = ドローコール1回
}

パーティクルが10000個なら、1フレームで10000回もドローコール(描画命令)を発行することになります。ドローコールはCPUからGPUへの「指示出し」で、1回1回にそれなりのコストがあります。GPUの計算力ではなく、CPUの指示出しが渋滞して重くなるのです。これが「ドローコール地獄」と呼ばれる状態です。

GPUインスタンシングの考え方

パーティクルは全部「同じ四角(形)」で、違うのは「位置・色・大きさ」だけです。だったら、こう頼めばいいはずです。

  • 形(四角のメッシュ)は1つだけGPUに渡す
  • 1個ずつ違うデータ(位置・色・大きさ)をリストにして渡す
  • 「この四角を、このリストの数だけ、それぞれの位置で描いて」と1回だけ命令する

これがGPUインスタンシングです。10000個でもドローコールは1回。CPUの渋滞が消えて、一気に軽くなります。似た「共有でムダを減らす」発想はフライウェイトパターンとも通じます。

仕組み|頂点バッファを2本使う

DirectXでは、頂点バッファを2本用意するのがポイントです。

  • バッファ①(頂点ごと)… 四角の4頂点。全インスタンス共通
  • バッファ②(インスタンスごと)… 各パーティクルの位置・色・大きさ

入力レイアウトで、バッファ②の要素を D3D11_INPUT_PER_INSTANCE_DATA(頂点ごとではなくインスタンスごとに1回進む)に指定します。これで「頂点は使い回し、インスタンスデータだけ1個ずつ切り替わる」という動きになります。

// 1個のパーティクル分のインスタンスデータ
struct InstanceData {
    XMFLOAT3 pos;    // 位置
    XMFLOAT4 color;  // 色
    float    size;   // 大きさ
};

D3D11_INPUT_ELEMENT_DESC layout[] = {
    // バッファ0:頂点ごと(四角の形)
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
      D3D11_INPUT_PER_VERTEX_DATA, 0 },

    // バッファ1:インスタンスごと(位置・色・大きさ)
    { "INST_POS",   0, DXGI_FORMAT_R32G32B32_FLOAT,   1, 0,
      D3D11_INPUT_PER_INSTANCE_DATA, 1 },   // ← 最後の 1 が肝
    { "INST_COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT,1, 12,
      D3D11_INPUT_PER_INSTANCE_DATA, 1 },
    { "INST_SIZE",  0, DXGI_FORMAT_R32_FLOAT,         1, 28,
      D3D11_INPUT_PER_INSTANCE_DATA, 1 },
};

最後の引数(InstanceDataStepRate)を 1 にすると、「1インスタンス描くごとにデータを1つ進める」という意味になります。ここが 0(頂点データ)のままだと正しく動きません。

実装|毎フレーム更新して1回で描く

パーティクルの計算結果を、毎フレームでインスタンスバッファに書き込み、最後に DrawIndexedInstanced で一気に描きます。

// ① インスタンスバッファへ今フレームの結果を書き込む
D3D11_MAPPED_SUBRESOURCE mapped;
context->Map(instanceBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped);
memcpy(mapped.pData, instances.data(),
       sizeof(InstanceData) * instances.size());
context->Unmap(instanceBuffer, 0);

// ② 頂点バッファを2本まとめてセット(スロット0と1)
ID3D11Buffer* buffers[] = { vertexBuffer, instanceBuffer };
UINT strides[] = { sizeof(Vertex), sizeof(InstanceData) };
UINT offsets[] = { 0, 0 };
context->IASetVertexBuffers(0, 2, buffers, strides, offsets);
context->IASetIndexBuffer(indexBuffer, DXGI_FORMAT_R16_UINT, 0);

// ③ 四角(インデックス6個)を、インスタンス数ぶん、1回で描く
context->DrawIndexedInstanced(
    6,                       // 1インスタンスのインデックス数(四角=2三角)
    (UINT)instances.size(),  // インスタンス数(=パーティクル数)
    0, 0, 0);

頂点シェーダー側では、インスタンスデータを受け取って各パーティクルの位置に配置します。

struct VSIn {
    float3 pos      : POSITION;    // 頂点ごと(四角)
    float3 instPos  : INST_POS;    // インスタンスごと
    float4 instCol  : INST_COLOR;
    float  instSize : INST_SIZE;
};

VSOut main(VSIn v) {
    VSOut o;
    // 四角を大きさ倍して、インスタンスの位置へ動かす
    float3 world = v.pos * v.instSize + v.instPos;
    o.pos   = mul(float4(world, 1), viewProj);
    o.color = v.instCol;
    return o;
}

これで、何千個のパーティクルでもドローコールはたった1回になります。

【重要】私がインスタンシングで詰まった体験談

初めてインスタンシングを組んだとき、パーティクルが全部「画面の中央の1点」に固まって出るという状態になりました。数は合っているのに、位置がバラけない。

原因はInstanceDataStepRateの設定漏れでした。入力レイアウトの分類を PER_VERTEX_DATA のままにしていたので、GPUがインスタンスデータを「1個目のまま」読み続け、全員が同じ位置になっていたんです。D3D11_INPUT_PER_INSTANCE_DATA とステップレート 1 に直したら、一発でバラけました。

「数は合うのに位置がおかしい」ときは、だいたいインスタンスデータの分類か、頂点バッファのstride/offsetが怪しい、と体で覚えた出来事でした。

GPUインスタンシングのよくある失敗例と対処法

①InstanceDataStepRateの付け忘れ

体験談のとおり、全インスタンスが同じ場所に出ます。インスタンス用の要素は必ず PER_INSTANCE_DATA +ステップレート1にしましょう。

②2本のバッファのstride/offsetを取り違える

IASetVertexBuffers にstrides配列を渡すとき、頂点用とインスタンス用でサイズが違うのに同じ値を入れてしまうミスが多いです。それぞれ sizeof(Vertex)sizeof(InstanceData) を正しく渡します。

③DrawIndexedInstancedを使っていない

うっかり普通の DrawIndexed のままだと、1個しか描かれません。インスタンス版の描画関数を使い、インスタンス数を正しく渡すのを忘れないでください。

注意点

  • インスタンスバッファは毎フレーム書き換えるので DYNAMIC で作り、MAP_WRITE_DISCARD で更新する
  • 効果が出るのは「同じメッシュを大量に」のとき。数個なら普通に描くほうが速い
  • パーティクルの生成・破棄はプールで管理すると更新も軽い(オブジェクトプールと相性◎)

まとめ

  • GPUインスタンシングは同じメッシュを1回の描画で大量に出す仕組み
  • 重さの正体はドローコールの数。1回にまとめてCPUの渋滞を消す
  • 頂点バッファを2本(頂点用・インスタンス用)使い、DrawIndexedInstancedで描く
  • ハマりどころはステップレートとstride/offset

パーティクルは、インスタンシングを覚えると出せる数が一気に跳ね上がります。まずは「四角1枚+位置リスト」から試してみてください。

関連記事