最近のOpenGLにおける効率的なDrawCallを考える

過去数回にわたり、OpenGL4.4と幾つかのARB拡張の機能について勉強してきました。
これを用いて、効率的なDrawCallを考えてみたいと思います。

効率的なDrawCallとは?

ここで議論する効率的なDrawCallとは、一言で言えば、多数のDrawCallの呼び出しがあっても、CPU負荷が少なく、それによるCPU側の処理時間が少ないものを指します。GPUの絶対的な性能を向上させるものではありません。
しかし近年は、GPUのプリミティブの処理速度が高速になっていく一方で、CPU側から効率的にDrawCallを発行する方法がありませんでした。結果として、特にDX9などのAPIでは、CPUが描画コマンドを作成する部分に、レンダリング全体のボトルネックが発生することも少なくありません。(以下これを、DrawCallボトルネックと呼ぶ)
効率的なDrawCallを確立することが出来れば、パフォーマンスの向上だけでなく、現在はアーティスト作業の重荷になっている、オブジェクトのマージ作業や、テクスチャのアトラスの作成といった作業を軽減することが可能になるかもしれません。

効率的なDrawCallを考える

前提とする条件

OpenGL4.x
GL_ARB_shader_draw_parameters
GL_ARB_bindless_texture
を用いる環境と仮定します。

ModelViewProjectionマトリクスについて

DrawCallの呼び出しにおいて、ModelViewProjectionマトリクスを用意することは、ほぼ常識として考えられてきました。
しかし、近年のGPUの性能を鑑みると、マトリクス同士の乗算によって、消費されるALUの演算リソースよりも、メモリの入出力による遅延の方が、影響が大きい場合もあるようです。
ここで、Modelマトリクスと、ViewProjectionマトリクスを、VertexShader上で乗算するように変更すると、1フレームのレンダリング中に、ひとつのオブジェクトに対するModelマトリクスの更新は1度行えばよく、ShadowMapやDepth-Prepass, G-Bufferといった、各パスの描画のたびにマトリクスを演算し、UniformBufferを更新する作業を減らすことが出来ます。
一方で、GPU側のVertexShaderの計算量は増えますが、DrawCallボトルネックの状態の場合、確実にCPUの処理とAPIの呼び出し回数を軽減し、パフォーマンス向上につながることになります。

Modelマトリクスをどこに格納するか

Modelマトリクスを格納する場所として考えられるのは、UniformBufferObject(以下UBO)かShaderStorageBufferObject(以下SSBO)です。
BufferObjectひとつに対して、UBOが最低スペックの場合16KBまでしか確保できないのに対して、SSBOは最低でも16MBまでの領域が確保可能です。SSBOをModelマトリクスの格納場所として使用すれば、1フレーム分のDrawCallのマトリクスの格納場所として、十分な領域が確保可能と思われます。

DrawCallに対して配列のインデックスを渡す方法

上記のように、Modelマトリクスを単一のBufferObjectに配列で格納した場合、各DrawCallに対して、適切に配列のインデックスを通知する必要があります。
配列のインデックスを通知する方法として最も簡単な方法のひとつとして、BaseInstanceを用いる方法が挙げられると思います。

glDrawElementsInstancedBaseInstance(GL_TRIANGLES,
                                    nbPrim,
                                    GL_UNSIGNED_SHORT,
                                    NULL,
                                    1,
                                    (GLuint)arrayIndex);
#version 440
#extension GL_ARB_shader_draw_parameters : require
in int gl_BaseInstanceARB;

void main()
{
  int did = gl_BaseInstanceARB;
  mat4 World = Transforms[did];
  vec4 worldPos = World * vec4(In.v3Pos, 1);
  gl_Position = ViewProjection * worldPos;
}

上記の例では、インスタンス描画用のAPIを用いていますが、インスタンス数は1で描画しますので、実際にはインスタンス描画ではありません。ここで、BaseInstanceに配列のインデックスを渡すことで、VertexShader上で、配列のインデックスを得ることが出来ます。
この方法では、頂点配列をマージする必要もないので、簡単に試すことが出来ます。DrawCallのパフォーマンスを向上させたい場合の、最初の一手として試す価値があるかもしれません。
一点、短所として挙げられるのは、この方法は、本当のインスタンス描画とシェーダーを共有することが難しいということです。
インスタンス描画で、divisorを用いた頂点配列を用いたケースでは、BaseInstanceは0、もしくは適切な値を設定する必要があります。ただし、一般的にインスタンス描画を行う場合は、専用のシェーダーを用意するケースが多いと思われるので、この短所はあまり致命的ではないと思われます。

配列に格納可能な他の要素

DrawCallに関連する、他の要素として、各種Shaderのパラメーターや、Textureなどが考えられます。もし、これ等を配列に格納することが出来れば、上記と同様の手法でShaderからアクセスすることが可能になります。これによって、従来では、DrawCallごとに頻繁に発生していた、BufferObjectの更新や、各種Stateの変更を避けることが可能になります。
Textureを配列に格納する方法は、幾つか考えられますが、最も自由度が高いのが、BindlessTextureを用いる方法です。これを用いれば、各Textureの解像度やチャンネル数、フォーマットなどが異なった場合でも、同一の配列に格納可能です。
Shaderに関連するパラメーターは、各Shaderによって異なることが考えられますが、最大公約数的な構造体を作成することで、同一の構造体配列を、複数のShaderで扱うことが可能だと思われます。
これ等を、SSBOバッファに用意した配列に格納することで、Modelマトリクスの例と同様の手法を適用することが可能となります。
下記の例では、Bindlessテクスチャを用いて、PixelShader上でTexture配列にアクセスする例です。PixelShaderでは、VertexShaderより配列のインデックスを取得することになります。

#version 440
#extension GL_ARB_separate_shader_objects : require
#extension GL_ARB_bindless_texture : require

layout (std430, binding = 0) readonly buffer CB1
{
  uint64_t texHandle[];
};

layout(location=0) in struct {
  vec2 v2TexCoord;
  flat int iDrawID;
} In;

layout(location=0) out struct {
  vec4 v4Color;
} Out;

void main()
{
  Out.v4Color = vec4(texture(sampler2D(texAddress[In.iDrawID]),
                             In.v2TexCoord).xyz,  1);
}

MultiDraw系を用いる

MultiDraw系のAPIを用いることで、複数のDrawCallをマージし、1回のAPIの呼び出しにまとめることが出来ます。
ただし、マージするための条件として、複数のDrawCallが、同一のシェーダーで描画され、さらに同一の頂点フォーマットであるという制限があります。もちろんですが、描画中にRenderStateの変更などは出来ません。これ等の制限に適合するオブジェクトが多数あるケースでは、MultiDraw系を用いることで、さらにDrawCallの呼び出しを最適化することが出来ると思われます。
注意点として、MultiDraw系を用いる場合は、上記で述べた、BaseInstanceによる配列インデックスの指定とDrawIDを混同しないようにする必要があります。MultiDraw系のAPIで描画された際に、何番目のDrawCallであるかを知るには、gl_DrawIDARBにアクセスしますが、BaseInstanceによる配列インデックスを用いている場合は、この値はあまり意味がありません。代わりに、描画コマンド構造体の、baseInstanceに適切な値を設定する必要があります。

in int gl_DrawIDARB;
in int gl_BaseInstanceARB;
for (int i = 0; i < count; ++i)
{
  DrawElementsIndirectCommand *cmd = &commands[i];
  cmd->count = nbPrims[i];
  cmd->instanceCount = 1;
  cmd->firstIndex = 0;
  cmd->baseVertex = 0;
  cmd->baseInstance = arrayIdcs[i];
}

//注:Indirect系の描画コマンド配列は、CoreProfileの場合はDRAW_INDIRECT_BUFFERを通じて指定する必要があります。
glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_SHORT, commands, count, 0);

まとめ。考察

 まず、今回は、実際にこれ等を実践することで得られる、パフォーマンスの向上について検証していません。できれば、実際のゲームのシーンで、従来のDrawCallの手順と、上記を実践した場合に得られるパフォーマンスの差を検証してみたいのですが、簡単に手に入るアセットでは、なかなか適当なものがありません。
たとえ実際のゲームのシーンを入手できても、既にテクスチャアトラスが作られ、マテリアルなどがマージされ、DrawCallの数自体を抑えてあるものでは、上記を実践しても効果が薄いかもしれません。
 また、今回割愛しましたが、同一解像度の、非常に多数のTextureを使用する場合は、BindlessによるSSBO配列よりも、Texture2DArrayを用いた方が好ましいと思われます。理由は、Bindlessを使用した場合では、用いていない場合に比べ、TextureHandle1つあたり、32Bitレジスタ2本が消費されます。3つのTextureを参照すれば、6本となり、これは無視できない数字となります。また、Bindlessテクスチャは、基本的には、個々のOpenGLのTextureObjectなので、各々が独立したSamplerStateを保持しています。つまり、GPUはSamplerStateの切り替えがあると前提します。一方でTexture2DArrayで、別のSliceにアクセスしても、同一のTextureUnitが別のメモリにアクセスするだけです。特に、P-texやパーティクルなどで、プリミティブごとに異なるTextureにアクセスするようなケースでは、Texture2DArrayを用いることを検討する必要があると思います。