GL_NV_command_list 拡張について

GL_NV_command_list拡張は、描画リソースBindingと一部のRenderState変更を含んだ、一連のDrawCallを、少ないCPUオーバーヘッドで発行するための仕組みです。そのコンセプトは、MultiDrawIndirect系とは異なり、どちらかというとOpenGLに元来備わっているDisplayListに近いものとなります。なかなか大規模な拡張なので、順を追ってみていきたいと思います。

StateObjects

void CreateStatesNV(sizei n, uint *states);
void DeleteStatesNV(sizei n, const uint *states);
boolean IsStateNV(uint state);
void StateCaptureNV(uint state, enum mode);

StateObjectは、現在のOpenGLコンテキストの状態をObjectとして保存します。保存する際には、そのステートを用いて描画するPrimitiveの種類を引数”mode”に、描画に先んじて指定する必要があります。このオブジェクトに保存したステートを、コマンドリストでの描画時のステートとして使用します。
このオブジェクトに保存されるステートは、おおよそ以下の通りです。詳しくはOpenGL Registryに記載されている仕様を確認してください。

  • VertexAttribute,(Format,Type,Offset,Stride)
  • PrimitiveRestart
  • ImmediateVertexAttribute
  • Compute以外のProgram
  • RasterizationState
  • DepthStencilState
  • Viewport,Scissor,DepthRangeのステート(Index:0のViewport,Scissorの領域設定は含まない)
  • FramebufferAttachment(Attachmentの状態とそのフォーマット)

一方で、このオブジェクトに保存されないステートは、以下の通りです。

  • BindされたVertexBuffer,IndexBuffer,Texture,Sampler,
  • BlendConstantColor
  • StencilRefValue
  • AlphaTestThreshold
  • PolygonOffset
  • Viewport,Scissor領域(Index:0のみ)

FBOのIndex:0のみ少々特殊で、StateObjectに状態が保存されていますが、StateObjectの使用時に、同一フォーマットの他のFBOをOverrideして使用することが可能です。
ちなみに、TransformFeedback,OcclusionQuery,が有効になっている場合や、DefaultUniformBlock(FixedFunctionのシェーダー変数を含む),SSBO,AtomicCounterBufferなどをシェーダーが使用している場合、FBOではなく、BackbufferがBindされている場合は、INVALID_OPERATIONとなって、StateObjectのCaptureはできません。

DrwaingWithCommands

void DrawCommandsNV(enum primitiveMode,
                    uint buffer,
                    const intptr* indirects, const sizei* sizes, uint count);
void DrawCommandsAddressNV(enum primitiveMode,
                           const uint64* indirects, const sizei* sizes, uint count);

上記の関数は、どちらも事前に作成された一連の描画コマンドを実行するものになっています。コマンドトークンが格納されている”buffer”と各コマンドトークンの相対アドレスリスト”indirects”、コマンドトークンのサイズリスト”sizes”、そしてコマンドトークンの数”count”を指定するようになっています。もう一つの関数は、bufferを指定しない代わりに、コマンドトークンのUnifiedAddressのリストを渡すようになっています。各コマンドトークンは、いずれも既知の小さな構造体となっています。詳細はOpenGL Registryに記載されている仕様を参照してください。基本的にはOpenGLの各種関数の引数をそのまま積み込んだ内容となっています。

トークンの実行時は、VERTEX_ATTRIB_ARRAY_UNIFIED_NV,ELEMENT_ARRAY_UNIFIED_NV,UNIFORM_BUFFER_UNIFIED_NVがEnableになっている状態として動作します。つまり、VertexBuffer,IndexBuffer,UniformBufferをBindする際には、BufferのIDではなくUnifiedAddressを用います。アドレスの取得はそれぞれのUNIFIED拡張と同様に行い、それをトークン構造体に格納します。

上記関数の呼び出し時に、FrameBufferのIndex:0にDRAW_FRAMEBUFFERがBindされいるとINVALID_OPERATIONになります。つまりFBOに対する描画には、後述するState付き描画を用いる必要があります。
コマンドトークンの種類は、VBO,IBO,UBOの設定と、DrawArray,DrawElementなど描画命令となっています。加えて、先ほどStateObjectに格納されなかった、BlendConstantColor,StencilRefValue,AlphaTestThreshold,PolygonOffset,Viewport,Scissor領域(Index:0のみ)などが設定できます。ただし、Texture,Samplerの設定はできません

uint GetCommandHeaderNV(enum tokenID, uint size)

コマンドトークン構造体の先頭には、必ずHeaderが格納されています。Headerの内容は実装依存なので、上記の関数にコマンドトークンのIDとトークン構造体のサイズを渡してHeaderの値を取得する必要があります。トークンサイズがドライバー側の実装と異なれば、INVALID_VALUEとなります。

Drawing with Commands and State Objects

void DrawCommandsStatesNV(uint buffer,
                          const intptr* indirects, const sizei* sizes, 
                          const uint* states, const uint* fbos, uint count);
void DrawCommandsStatesAddressNV(const uint64* indirects, const sizei* sizes, 
                                 const uint* states, const uint* fbos, uint count);

上記関数は、StateObjectを設定しつつ、コマンドトークンの実行します。”states”と”fbos”は長さ”count”の配列で指定することとなっています。つまりコマンドトークンの実行ごとにStateの変更が可能となっています。fboにはゼロを指定することが可能となっており、その場合はStateObjectに保存されているFBOがBindされます。fboにゼロでない値を指定した場合は、StateObjectに保存されているFBOに代わって、指定したFBOがBindされますが、条件としてStateObjectに保存されているFBOとAttachmentとTextureのフォーマットが一致している必要があります。一致しない場合は、INVALID_OPERATIONとなります。

Command Lists

void CreateCommandListsNV(sizei n, uint *lists);
void DeleteCommandListsNV(sizei n, const uint *lists);
void CommandListSegmentsNV(uint list, uint segments);

上記関数は、一連のDrawCommandsStateNV()呼び出しをオブジェクト化するためのものです。”segments”で指定した数だけDrawCommandsStateNV()に相当する一連のコマンドトークンを格納可能です。デフォルトのセグメント数は1となっています。

void ListDrawCommandsStatesClientNV(uint list, uint segment,
                                    const void** indirects, const sizei* sizes,
                                    const uint* states, const uint* fbos, uint count);

上記関数で、指定したセグメントにコマンドトークンを格納します。この関数の実行時に、コマンドトークンはすべてこのオブジェクト内にコピーされます。StateObjectの内容やFBOの設定もコピーされます。またその際には、StateObjectやFBOのリファレンスが格納されるのではなく、その時点のStateObjectの内容やFBOの指しているオブジェクトのアドレスなどが保存されます。つまりこの時点までにStateObjectの作成と、FBOのResident化を完了していなくてはなりません。

void CompileCommandListNV(uint list);
void CallCommandListNV(uint list);

上記関数は、CommandListのコンパイル(最適化)と、呼び出しになります。

実際の描画をイメージしてみる

ここで実際の描画をイメージしてみたいと思います。
まず、CommandListを作成することを前提とするならば、BindされるFBOはGPU側のアドレスとして格納されるので、Resident化されている必要があります。同様に、コマンドトークンの描画で使用するTextureはBindlessTextureを使用するので、Resident化されている必要があります。

addr = glGetTextureHandleARB(textures.scene_color);
glMakeTextureHandleResidentARB(addr);

VBO,IBO,UBOもアドレスを使うので、Resident化する必要があります。

glGetNamedBufferParameterui64vNV(ibo, GL_BUFFER_GPU_ADDRESS_NV, &addr_ibo);
glMakeNamedBufferResidentNV(ibo, GL_READ_ONLY);

実際の描画では、大抵の場合はUBOの内容を書き換えつつBindして、DrawCallを呼び出すと思います。これをコマンドトークンの作成時に実現するためには、事前に十分に大きなUBOをResident化した上でMapしておけば、UBOの先頭から随時使用する領域を確保して、内容を書き込むことが出来ます。UBOのアドレスに関しては、GPU側のアドレスにCPU側のアドレスと同様のOffsetを適用すれば、コマンドトークンに正しいアドレスを設定することができるはずです。さらに、UBOにGL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BITを適用してMapしたままにしておけば、更新したUBOをUnmapせずに描画コマンドを発行することが可能です。使用する領域が重複しなければ、同じUBOを複数フレームにわたって使用することも可能です。
TextureはBindすることが出来ないので、事前にResident化した際のアドレスをUBOを介してShaderに渡します。SSBOもBindすることが出来ないので、同様の手法を使います。この辺りはNV_bindless_texture,NV_shader_buffer_load/store拡張を使うことになります。AtomicCounterBufferはBindできませんが、Atomic命令自体は使用可能ですので、SSBOへのポインタがあれば特に問題ないと思います。

コマンド発行のために、個々のコマンドトークンのGPU側の絶対アドレス、もしくはbufferObject先頭からの相対アドレスを保存しておく必要があります。絶対アドレスを用いる場合は、事前にトークンバッファのGPUアドレスを取得して、上記UBOと同じ方法を用いる方法が適当だと思います。相対アドレスを使う場合は、CPU側の一時バッファ上で一連のコマンドトークンを作成し、genBuffer()したオブジェクトにglNamedBufferStorageEXT()を用いて転送し、そのbufferObjectを引数で渡して描画すれば良いと思います。
ただ、これに関して一点不明な点があります。コマンドトークンを格納するバッファの種類がExtensionに記載されていません。上記glNamedBufferStorageEXT()の場合は、バッファの種類に関係なくデータを転送できるので問題ないのですが、DSA以外の関数ではバッファの種類が必要になるので困ります。

雑感

上記のように、使用する描画リソースを事前に全てResident化して、GPU側のアドレスを取得しておけば、GLのAPIを一切呼び出すことなく、一連のコマンドトークンを作成可能です。これは、完全にスレッドセーフな状態でオブジェクトの描画コマンド作成が可能ということを意味しています。これは大きなメリットだと思います。
しかし、一見すると非常に有用なCommandList拡張ですが、StateObject側にVertexAttributeのStateが格納されています。つまり、頂点フォーマットを変更する場合は新たなStateObjectが必要になります。そしてGLコンテキストのStateの変更とStateObjectの生成にはGLの各種APIの呼び出しが必要なため、スレッドセーフではなくなります。動的にStateObjectの生成を行うと、生成に伴うクリティカルセクションの管理が必要になってしまいます。
一つの方法として考えられるのは、事前に十分な数のStateObjectのIDを確保しておき、描画コマンド生成中にStateObjectの変更の必要があった場合は、そこで変更の必要なステートの内容を保存しておき、StateObjectのIDのみを確保します。StateObjectの生成はせずに、そのまま確保したIDを用いて引き続きコマンドトークンの生成を行います。全描画コマンドトークンの生成が終了して、すべての描画スレッドがマージされてから、OpenGLのコンテキストを使って、必要となるStateの生成を行い、その後に、コマンドトークンの実行を行うという方法です。これならクリティカルセクションの管理は必要ありません。

FBOのOverride機能は正直微妙な気がします。特殊なケースを除けば、FBOの切り替え時には素直にStateObjectを作成しても性能に影響が出るほどのことはないのではないかと思います。

最後にGPUの使用メモリに関してですが、ほぼすべての描画リソースをResident化しなければならないので、ひとたびGPUの搭載メモリ以上の描画リソースが必要になったらほぼ描画不可能な状態に陥ってしまいます。これを回避するためには高度なリソース管理が必要になると思います。この点は注意が必要です。
また、描画時に使用するすべてのリソースは、GLのAPI上で実行したコマンドトークンが、実際にGPU上で実行完了するまで、その内容とResident化を保持する必要があります。特にフレームごとに内容を更新するUBOや、コマンドトークンを格納するBufferObjectに関しては取り扱いに注意が必要です。glFenceSync()を活用し、描画フレーム単位の実行完了確認を行いながら、リソースの破棄や再利用を行う必要があります。

NVIDIAのサンプルがGitHubに上がってます

http://nvidiagameworks.github.io/GraphicsSamples/NVCommandListSample.htm

私のPCで、上記サンプルを実行した限りでは、CommandListを作成する場合と、コマンドトークンバッファをそのまま実行する場合では、それほど大きな性能差がみられませんでした。コマンドトークンを何度も再利用しない限りは、無理してコマンドリストを作成する必要は無いかもしれません。

広告