DirectXでのゲーム作りで、パーティクルをたくさん出したい!線を引きたい!デバッグ表示をしたい!という想いから調べてみたらたどり着いた。インスタンス描画とかいう奴。
同じ見た目をしているスプライトを、位置情報や大きさなどを一括で送り、描画することで、描画命令の回数が減って軽くなる!という感じらしい。のだが、実装するうえで色々詰まったところがあったので学びも兼て書き残しておく。
比較してみる
FPSを無制限にした状態で動かしてみると、かなりパフォーマンスへの影響に差があると分かる。
(gifなので分かりにくいけど、左が100fps↑、右が10~18fpsあたり)
インスタンス描画をざっくりと見てみる
↓以下のサイトを参考にさせていただきました。
http://www.marupeke296.com/DXG_No69_Instancing.html
DirectX11でスプライトを描画したい時、基本はこんな雰囲気のコードを書くが、今回大事なのは描画命令の部分。
基本のDrawSprite
void DrawSprite(XMFLOAT2 pos, XMFLOAT2 size, XMFLOAT4 color)
{
//----------------------------------------------------
// 頂点バッファをロック
//----------------------------------------------------
D3D11_MAPPED_SUBRESOURCE msr;
g_pContext->Map(g_pVertexBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &msr);
// 頂点バッファへの仮想ポインタを取得 memcpyとかで後から詰めるやり方もある
Vertex* v = (Vertex*)msr.pData;
// 指定の位置に指定のサイズ、色の四角形を描画する
XMFLOAT2 halfSize = { size.x / 2.0f,size.y / 2.0f };
v[0].position = { pos.x - halfSize.x,pos.y - halfSize.y,0.0f };
v[0].color = color;
v[0].texCoord = { 0.0f,0.0f };
v[1].position = { pos.x + halfSize.x,pos.y - halfSize.y,0.0f };
v[1].color = color;
v[1].texCoord = { 1.0f,0.0f };
v[2].position = { pos.x - halfSize.x,pos.y + halfSize.y,0.0f };
v[2].color = color;
v[2].texCoord = { 0.0f,1.0f };
v[3].position = { pos.x + halfSize.x,pos.y + halfSize.y,0.0f };
v[3].color = color;
v[3].texCoord = { 1.0f,1.0f };
// 頂点バッファのロックを解除
g_pContext->Unmap(g_pVertexBuffer, 0);
// 頂点バッファを描画パイプラインに設定
UINT stride = sizeof(Vertex);
UINT offset = 0;
g_pContext->IASetVertexBuffers(0, 1, &g_pVertexBuffer, &stride, &offset);
// プリミティブトポロジ設定 トライアングルストリップ
g_pContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
// ポリゴン描画命令発行
g_pContext->Draw(4, 0);
}
// ポリゴン描画命令発行
g_pContext->Draw(4, 0);
このDraw関数が重たい。1フレーム内で1000とか2000とか呼び出していい関数じゃない。
そのため、似たような仕事は小分けにしないで一括で渡そう!というのがインスタンス描画らしい。
ただ調べてみると、なんだか色んな人が色んなやり方でやっている。インスタンスバッファというのと、SV_InstanceIDという単語が見えたり見えなかったり。どうやら作り方が2通りある?それぞれのイメージをざっくりまとめてみる。
方式①:インスタンス頂点バッファ
頂点レイアウトにインスタンシングのための属性を追加しておき、スロット1(スロット0以外)にID3D11Bufferで作ったインスタンス用の頂点バッファをバインドする。
シェーダー側ではD3D11_INPUT_PER_INSTANCE_DATAとして宣言した属性が、インスタンスごとに1回進んで供給される。
DrawInstanced() を呼ぶと、頂点数×インスタンス数分の頂点シェーダ呼び出しが自動的に行われる。
・頂点レイアウト
スロット0:通常の頂点情報(位置、色、UV座標など)
スロット1:インスタンスごとのデータ(World行列、色など)
方式②:定数バッファ(cbuffer)に配列で渡し、SV_InstanceIDで値を得る
定数バッファへのバインドは通常と変わらず。ここから値を得るためのSV_InstanceIDはhlsl内で宣言するだけで使うことが出来る。方式①では値の供給はGPUが勝手にしてくれていたが、こちらではSV_InstanceIDを使って自分で値を得る。方式①でのGPUの仕事を半分自分でやるイメージ?
// 宣言するだけ
uint InstanceId : SV_InstanceID;
cbuffer InstanceBuffer : register(b1) {
float4x4 worldMatrices[256];
};
方式①と比べると、既存のシステムから改修しないといけない点が少ないのがメリット。ただ、定数バッファのサイズ上限などで、あまり多くの描画は期待できない。
(私の環境では256が限界でした。World行列だけでこれ)
実際に作ってみた
方式①を採用して作ってみた。こっちの方が王道っぽい雰囲気。
用意するもの
・インスタンス用の構造体
行列(位置、回転、大きさ)、色、UV座標の3つにしてみた。GetIdentity()は通常描画に使うためのデフォルト値が取得できる。
struct InstanceData {
DirectX::XMMATRIX world;
DirectX::XMFLOAT4 color;
DirectX::XMFLOAT4 uvRect;
static InstanceData GetIdentity() {
InstanceData identity;
identity.world = DirectX::XMMatrixIdentity();
identity.color = { 1.0f,1.0f,1.0f,1.0f };
identity.uvRect = { 0.0f,0.0f,1.0f,1.0f };
return identity;
}
};
・インスタンスバッファの生成
作り方は頂点バッファとほぼいっしょ。
static ID3D11Buffer* g_pInstanceBuffer = nullptr;
// インスタンスバッファ生成
{
D3D11_BUFFER_DESC bd = {};
bd.Usage = D3D11_USAGE_DYNAMIC;
bd.ByteWidth = sizeof(InstanceData) * MAX_INSTANCE;
bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
bd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
g_pDevice->CreateBuffer(&bd, NULL, &g_pInstanceBuffer);
}
・インスタンス情報が含まれた頂点レイアウト
constexpr D3D11_INPUT_ELEMENT_DESC INPUT_LAYOUT[] = {
// slot0 共通
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, D3D11_APPEND_ALIGNED_ELEMENT, D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, D3D11_APPEND_ALIGNED_ELEMENT, D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, D3D11_APPEND_ALIGNED_ELEMENT, D3D11_INPUT_PER_VERTEX_DATA, 0 },
// slot1 インスタンス
{ "WORLD", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, D3D11_APPEND_ALIGNED_ELEMENT, D3D11_INPUT_PER_INSTANCE_DATA, 1 },
{ "WORLD", 1, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, D3D11_APPEND_ALIGNED_ELEMENT, D3D11_INPUT_PER_INSTANCE_DATA, 1},
{ "WORLD", 2, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, D3D11_APPEND_ALIGNED_ELEMENT, D3D11_INPUT_PER_INSTANCE_DATA, 1 },
{ "WORLD", 3, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, D3D11_APPEND_ALIGNED_ELEMENT, D3D11_INPUT_PER_INSTANCE_DATA, 1 },
{ "INS_COLOR",0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, D3D11_APPEND_ALIGNED_ELEMENT, D3D11_INPUT_PER_INSTANCE_DATA, 1 },
{ "INS_UVRECT",0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1,D3D11_APPEND_ALIGNED_ELEMENT, D3D11_INPUT_PER_INSTANCE_DATA, 1 },
};
全体の流れ
エフェクトとして使うことを前提に考えていたので、ParticleManagerのDraw()で呼び出してみた。
テクスチャをセットした後、描画準備 → インスタンス追加(ループ) → 描画命令 の順で処理する。(Particleを追加するためのEmit()などもあるが、インスタンス描画とは関係ないため今回は割愛する)
void ParticleManager::Draw()
{
for (Emitter& emitter : m_emitterList ) {
// テクスチャをセット
emitter.m_texture->BindTexture();
//----------------------------------------------------
// インスタンス描画処理
//----------------------------------------------------
dxPrepareDrawInstance();
for (ParticleData& data : emitter.m_particles) {
if (!data.m_use)continue;
ParticleInstance& particle = data.m_instance;
MiFloat2 pos = { particle.m_pos.x ,particle.m_pos.y };
bool correct = dxAddInstance(
pos,
{ particle.m_size.x * sizeScale,particle.m_size.y * sizeScale },
particle.m_rot,
particle.m_color,
particle.m_uvRect
);
if (!correct)break;
}
dxDrawInstance();
// ...
}
テクスチャをバインドした後の話をする。
①描画前準備
DrawSprite()では頂点バッファを編集することで、位置や回転、大きさ、色、UV座標などなど……。を変更していたが、インスタンス描画はそれらの役目を全てインスタンスバッファが担うので、頂点バッファには加工しやすいシンプルな値を入れておく。
dxPrepareDrawInstance():インスタンス描画準備
//===================================================
// インスタンス描画準備
//===================================================
void dxPrepareDrawInstance() {
//----------------------------------------------------
// 頂点バッファの編集
//----------------------------------------------------
D3D11_MAPPED_SUBRESOURCE msr;
g_pContext->Map(g_pVertexBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &msr);
// 頂点設定
Vertex* v = (Vertex*)msr.pData;
v[0].position = { -0.5f,-0.5f,0.0f };
v[1].position = { +0.5f,-0.5f,0.0f };
v[2].position = { -0.5f,+0.5f,0.0f };
v[3].position = { +0.5f,+0.5f,0.0f };
for (int i = 0; i < 4; i++) {
v[i].color = { 1.0f,1.0f,1.0f,1.0f };
}
v[0].texCoord = { 0.0f,0.0f };
v[1].texCoord = { 1.0f,0.0f };
v[2].texCoord = { 0.0f,1.0f };
v[3].texCoord = { 1.0f,1.0f };
// 頂点バッファのロックを解除
g_pContext->Unmap(g_pVertexBuffer, 0);
//----------------------------------------------------
// インスタンスバッファの編集準備
//----------------------------------------------------
g_pContext->Map(g_pInstanceBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &msr);
// データ場所設定・カウントリセット
g_pInstanceData = (InstanceData*)msr.pData;
g_InstanceCount = 0;
}
その上で、インスタンスバッファを後で編集できるようにメモリ領域の確保&アドレスの保持もしておく。
g_pContext->Map(g_pInstanceBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &msr);
// データ場所設定・カウントリセット
g_pInstanceData = (InstanceData*)msr.pData;
g_InstanceCount = 0;
②インスタンスの追加
編集場所[0]を参照 → データをセット → カウントを増やす → 次の編集場所[1]を参照 …
と繰り返していくだけの処理。
dxAddInstance():インスタンス追加
//===================================================
// インスタンス追加
//===================================================
bool dxAddInstance(MiFloat2 pos, MiFloat2 scale,float rot, MiFloat4 color,MiFloat4 uvRect) {
if (g_InstanceCount < 0 || g_InstanceCount >= 1024)return false;
// 編集場所を参照する
InstanceData& data = g_pInstanceData[g_InstanceCount];
// データをセット
DirectX::XMMATRIX worldMat =
DirectX::XMMatrixScaling(scale.x, scale.y, 1.0f) * // 拡大
DirectX::XMMatrixRotationZ(rot) * // 回転
DirectX::XMMatrixTranslation(pos.x, pos.y, 1.0f); // 平行移動
data.world = worldMat;
data.color = { color.x,color.y,color.z,color.w };
data.uvRect = { uvRect.x,uvRect.y,uvRect.z,uvRect.w };
// カウントを増やす
g_InstanceCount++;
return true;
}
インスタンスについてはこれでいいのだが、ワールド座標の計算で思わぬつまずきをしたので書いておく。
※行列での注意点
行列には列優先、行優先とあるが、シェーダー側で使うのが列優先(column_major)行列だった場合、作ったWorldMatrixをTranspose(転置) する必要があった。逆に言えば行優先(row_major)行列の場合はTransposeしちゃダメ。
float4x4のデフォルトが列優先なのか行優先なのかは環境によって違うようなので、明示して書いておくと安心。(私の環境ではデフォルトがrow_majorで参考サイトと異なったので、本来不要なTransposeを記述しており大変なことになった)
DirectX::XMMATRIX worldMat =
DirectX::XMMatrixScaling(scale.x, scale.y, 1.0f) * // 拡大
DirectX::XMMatrixRotationZ(rot) * // 回転
DirectX::XMMatrixTranslation(pos.x, pos.y, 1.0f); // 平行移動
// Transposeが必要か、必要でないかが大事
XMFLOAT4X4 world;
XMStoreFloat4x4(&world, XMMatrixTranspose(worldMat));
// .hlsl
row_major float4x4 worldMatrix = float4x4(...); // 行優先。これならTranspose不要
column_major float4x4 worldMatrix = float4x4(...); // 列優先。こちらは必要
③描画命令
後は簡単。頂点バッファ、インスタンスバッファ共に描画パイプラインに設定した上で、いつも通り描画命令を出す。
※描画命令の関数だけ、g_pContext->Draw(4,1,0,0)から
g_pContext->DrawInstance(4,g_InstanceCount,0,0) に変更する。
dxDrawInstance():インスタンス描画
//===================================================
// インスタンス描画
//===================================================
void dxDrawInstance() {
// インスタンスバッファのロックを解除
g_pContext->Unmap(g_pInstanceBuffer, 0);
// 頂点バッファを描画パイプラインに設定
UINT stride = sizeof(Vertex);
UINT offset = 0;
g_pContext->IASetVertexBuffers(0, 1, &g_pVertexBuffer, &stride, &offset);
// インスタンスバッファを描画パイプラインに設定
stride = sizeof(InstanceData);
offset = 0;
g_pContext->IASetVertexBuffers(1, 1, &g_pInstanceBuffer, &stride, &offset);
// プリミティブトポロジ設定 トライアングルストリップ
g_pContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
// 描画命令
g_pContext->DrawInstanced(4, g_InstanceCount, 0, 0);
g_InstanceCount = -1;
}
④シェーダー側の処理
頂点シェーダーを編集する。もともとの頂点座標、頂点カラー、テクスチャ座標の変換処理に重ねる形で書いておく。
cpp側の処理でWorldMatrixをDirectXのデフォルトのまま使っているので、行優先(row_major)の行列として明示して書いておく。
頂点シェーダーの編集
struct VS_INPUT
{
// slot0: 頂点バッファ
float4 posL : POSITION0; // 頂点座標
float4 color : COLOR0; // 頂点カラー(R,G,B,A)
float2 texcoord : TEXCOORD0; // テクスチャ座標
// slot1: インスタンスバッファ
float4 world0 : WORLD0;
float4 world1 : WORLD1;
float4 world2 : WORLD2;
float4 world3 : WORLD3;
float4 instance_color : INS_COLOR0;
float4 instance_uvrect : INS_UVRECT0;
};
struct VS_OUTPUT
{
float4 posH : SV_Position; // 変換済み頂点座標
float4 color : COLOR0; // 頂点カラー
float2 texcoord : TEXCOORD0;
};
VS_OUTPUT main(VS_INPUT vs_in)
{
VS_OUTPUT vs_out;
// 行優先のmatrix、Transpose不要
row_major float4x4 worldMatrix = float4x4(vs_in.world0,vs_in.world1,vs_in.world2,vs_in.world3);
// 頂点変換
float4 worldPos = mul( vs_in.posL , worldMatrix );
vs_out.posH = mul( worldPos, mtx);
// 色はそのまま
vs_out.color = vs_in.color * vs_in.instance_color;
// テクスチャ座標
vs_out.texcoord = vs_in.texcoord * vs_in.instance_uvrect.zw + vs_in.instance_uvrect.xy;
return vs_out;
}
通常描画について
頂点シェーダーが共通な以上、通常の描画(インスタンシングを用いない単体の描画)でもインスタンスバッファにデフォルト値を入れる必要がある。
// インスタンスバッファの編集(identityを適用)
{
// インスタンスバッファをロックする
D3D11_MAPPED_SUBRESOURCE msr;
g_pContext->Map(g_pInstanceBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &msr);
// インスタンス設定
InstanceData* data = (InstanceData*)msr.pData;
*data = InstanceData::GetIdentity();
// インスタンスバッファのロックを解除
g_pContext->Unmap(g_pInstanceBuffer, 0);
}
こんな感じのをパイプライン設定の前に入れるだけ。後の処理は③のdxDrawInstance()とほぼ一緒。
// ここの引数はもちろん1にしておく。(単体なので)
g_pContext->DrawInstanced(4, 1, 0, 0);
活用方法の色々
実際、インスタンス描画で大量にスプライトを描けるようになって何をしたのか。の一部を載せてみる。
エフェクトとして使ってみた
線を引くのに使ってみた(UnityのLineRendererみたいなイメージ)
↑の動画にも映っているが、線の質点同士を大量の四角いスプライトで繋ぐことで、1本の線に見える。
以下はロープのデモ動画。滑らかなロープになるよう、かなり狭いスパンでスプライト描画をしているが、フレームレートが下がらない。
デバッグ表示に使ってみた
既に色んな動画で映っているが、実は原初の目的はこいつ。
VisualStudioの出力もいいんだけども。もっと直感的に見たいというか、なんというか。
ただ、DirectXで文字描画ができるDirectWriteがさっぱり分からなかったので、「デバッグ表示だけならASCIIコードに対応したテクスチャを1枚作って、それを分割して表示すればいいのでは?」と考えた。
1文字ごとにDraw()を呼び出しているので、デバッグ文字の有無でパフォーマンスに影響が出るのが問題だな…。と思っていた。
インスタンス描画実装により無事解決!
↓BMFontというソフトでこんな感じの画像を作り、インスタンス描画で表示するようにしました。
https://www.angelcode.com/products/bmfont/
最後に
沢山描画が出来るのはやっぱり楽しい。3Dゲームを作る時にも役立ちそうで楽しみ。
参考
http://www.marupeke296.com/DXG_No69_Instancing.html
https://hakase0274.hatenablog.com/entry/2019/05/25/224746
https://wizframework.github.io/BaseCross/00_14.html
なかなか情報が少ない知識だったので、非常にいろんなサイトにお世話になりました。先人の皆さんありがとう。