タグ別アーカイブ: DirectX

DirectX関連の話題

GPUViewのEvent ViewでDX11のPerf Eventを確認する

GPUViewのEvent ViewでDX11のPerf Eventを確認する手順です。
続きを読む

広告

Layered Reflective Shadow Maps for Voxel-based Indirect Illuminationについて

参照論文
Layered Reflective Shadow Maps for Voxel-based Indirect Illumination

IntelのARTのResearcherのMasamichi Sugiharaさんが第一著者のリアルタイムGI手法の論文です。
Voxel Cone TracingによるGI手法の一種といって差し支え無いと思います。
既存の手法(SVOGI)では、VoxelにOcclusionの情報とLightの情報を書き込みますが、本手法では、Voxel内に記述するのはOcclusionの情報のみで、Lightの情報はLayered Reflective Shadow Maps(LRSM)に格納するのが、既存手法との大きな違いです。

アルゴリズム概要

参照論文(Fig.3)に、アルゴリズムの概要が図で記載されています。
GIに関するパスは大きく分けて2つです。

Binary Voxelization

シーン内の遮蔽物(直接光を反射する物)のVoxel化を行います。Voxel情報は文字通りBinaryで、
VoxelがFullもしくはEmptyを記述します。つまり、1Voxelあたり1Bitの情報となります。加えて、効率的なCone TracingのためにMip Mapの生成を行います。

LRSM Construction

LRSMは各光源ごとに生成します。LRSMのソースとなるのは、RSMです。RSMは光源から見た、直接光の反射状況が記述されます。
具体的に、RSMには、

  • 直接光が反射された際に生じるRadiance(Reflected Radiance)
  • 光を反射した物体の法線ベクトル(Normal)
  • 光を反射した物体の光源からの距離(Depth)
  • が格納されています。
    通常のRSMを、そのままCone Tracingに用いると、Cornのサンプル半径が大きくなったときに、計算量(サンプル数)が大きくなるので、事前にフィルターを適用してこれを少ないコストで行えるようにします。簡単に言ってしまえば、MipMapのようなものを生成するのですが、生成の過程で問題になる、深度情報もしくは法線情報が大きく異なる隣接Pixelのフィルタリングを適切に行うための対策として、Layerを生成します。

    4.2 Reflective Shadow Map Pre-filtering

    RSMの各要素に対してpre-filterの適用、すなわちMipMapの生成を行います。
    その際に用いるfilterですが、Reflected RadianceとNormalに関しては、Hardware Texture Samplerを用いたBox Filterと記述されているので、線形のBox Filterを用いるようです。(実際には後述の理由により、そのままSamplerの補間機能を用いることはしていないと思います。)

    Depth Pre-filtering

    Depthは、RSMとLRSMで表現形式が異なります。
    まず、RSMを特定のCorn半径でサンプリングした場合を考えると、そのCornの深度の範囲内のDepthに分布しているRSMのピクセルのみが、Cornのサンプリングに寄与するべきです。しかしこれを大きなCorn半径で行うと、サンプリング数と計算コストが高くなります。
    LRSMでは、この部分を確率密度関数に置き換えています。具体的にはガウス分布を用いています。ガウス分布を計算するために、サンプル範囲の深度の平均値と分散値が必要になるので、深度と深度の2乗の値を計算し格納します。これをMipMap化しておくことで、Corn半径に応じたMipMapをサンプルすることで、深度分布の確率関数を少ないサンプル数で入手可能となります。

    4.3 Reflective Shadow Map Partitioning

    LRSMでは、文字通りLayerを生成するのですが、Layerのパーティションのキーとなるのが、RSMの法線と深度です。
    Pre-Filterを適用する前に、あらかじめ近しい法線と深度でLayer分けした後、Filterを適用することで、大きな品質の低下を防ぎます。
    法線に関しては、3要素のベクトルなので、dot(n, l)の値を用いて区分けをします。
    たとえば、深度方向でa個、dot(n, l)の値でb個パーティションを設定すれば、Layerの数は、(a+1)(b+1)となります。深度方向のパーティションの境界では、オーバーラップ区間と遷移のための関数を用い、スムースな遷移が行われるようにしています。

    5 Implementation

    Voxelの構築

    Voxelには、1Bitの情報しか記述しませんが、テクスチャフォーマットの制約上、1Byte/Voxelとなっています。

    LRSMの構築

    LRSMの格納には2DTexture Arrayを用います。LRSMの構築は大きく分けて2-passとなります。
    まずはじめにRSMを描画します。これには、Reflected Radiance, Depth, Normal, dot(n,l)を記述します。
    次にCompute Shaderで、各RSMをサンプリングし、Depthとdot(n,l)の値から、振り分けるLayerを決定し、同時に、Depthの値を、各Layerの深度範囲で正規化し、深度の2乗の値を計算します。
    LRSMの解像度は、RSMの1/2なので、RSM 4Pixelの平均値が、LRSMのLOD:0に格納されます。Compute Shaderの生成スレッドは、LRSMの解像度にあわせ、1スレッドあたりRSMの2x2pixelを処理します。LRSMの全てのLayerのLOD:0の生成が終われば、あとはMipMapを生成します。

    ライティングの計算

    ライティングの計算では、各G-bufferのPixelで、Final Gatheringを行います。基本的な手法はCorn Tracingとなりますが、Corn TracingでVoxelにHitした場合は、まず、その時のHit位置とCorn半径をRSMの座標系に投影し、サンプルするLRSMの位置とMipMapのレベルを決定します。MipMapのレベルは、log(dcircle)で計算されます。dcircleはRSMに投影されたCornの直径(texel)です。
    次に、Hitした場所の深度から、該当深度のLayerのLRSMをチェックします。まずはじめに、LRSMのNormalをサンプリングし、dot(N,Vc) (Vc:Cone Tracingの方向)を計算し、これが負になる場合は、Corn Tracingの向きに対して、裏面であると考え棄却します。
    次に、LRSMのDepthとDepth^2をサンプリングし、深度分布の関数を算出し、該当深度が分布関数で0以上かを調べ、0の場合は該当のLRSMが深度範囲外とし棄却します。
    次に、LRSMのReflected Radianceをサンプリングし、深度Layerの遷移関数、深度分布の確率とdot(N,Vc)を乗算し、Tracingの結果とし、該当するLayer全ての結果を加算します。

    6 Results

    参照論文に、Voxel Corn Tracingとの実行時間と、品質の比較が載っています。

    考察

     本手法の一番のポイントは、なんといってもVoxelに光源情報を書き出さないことだと思います。SVOGIではOctree構築とLightのInjectionの部分が明らかに複雑でしたが、本手法では、Voxelizingは最も明快な部分といえると思います。LRSMの構築も、不明瞭な点がなく、SVOGIと比較し素早く実装が可能だと思います。

     Diffuse Cornのサンプリングにかかる時間がSVOGIに比べて5倍以上の時間がかかりますが、Specular Cornのサンプリングは、20%程度高速となっています。Specularに関してはOctreeの追跡コストから来るものと思われ、基本的に本手法ではCorn TracingにかかるコストはSVOGIよりも高いものと思われます。これは、どちらが優れているということではなく、SVOGIではCorn TracingのためにVoxelにRSMの情報を埋め込むのに対し、本手法ではそれを行わない代わりに、LRSMを構築し、Corn Tracingを行うときにコストをかけるようにしているという違いです。近年では、Corn Tracingをフレーム間で分割して行い、再構成する手法が開発されており、Corn Tracingにかかる時間(サンプル数)は削減可能なため、本手法のほうが高速化に関しては可能性が大きいと思います。

     もうひとつの大きな違いは、Voxelが必要とするメモリ領域です。本手法では、必要とするVoxel情報が1Bit/Voxelなので、Octreeの構築を行わなくても1024^3のVoxelを構築可能です。本論文では512^3まで実装していますが、これは、1Byte/Voxelのメモリ割り当てで実装したためで、1Bit/Voxelで実装すれば1024^3解像度のVoxel空間を、現実的な速度で実装可能だと思われます。これにより、1024^3解像度までのVoxel空間ならば、SVOGIで行っていたOctree構築と追跡の手間が省略可能となります。
    対して、LRSMの構築と評価は、光源ごとに必要となります。多数の光源を配置したGIでは、多数のLRSMの構築と評価が必要となるため、光源の数は多くても数個までだと思われます。また、SVOGI手法では比較的簡単なEmissive Objectの反映も本手法では難しいです。逆に、太陽などの支配的な単一光源があるシーンでは、本手法が適していると思われます。

    余談: 1Bit/Voxelを使う

     DX11のテクスチャフォーマットには、1Bit/Texelのフォーマットはありません。したがって、R8_UNROMのテクスチャの8Bitをうまく使う必要があります。幸いにして、8Bitは丁度2x2x2のVoxelに見立てることが出来るので、1Bit/VoxelのVoxel空間は、解像度が半分の8Byte/Voxelで表すことが出来ます。
    Bitの書き込みには、Atomic命令のAndもしくはOrを使用する必要があります。Atomic命令は4Byteアラインのアドレスに対して適用可能なので、BitMaskをうまく調整して実行することで、1Bitずつ書き込むことが出来ます。読み出しは、対応するBitMaskが生成できれば、読み出したTexelとAndを取るだけで、こちらはAtomicの必要がありません。
     ちなみにこれは、GeForce GTX680で動作が確認されていますが、正しく動作することが永続的に保障されるものではありません。あくまで実験的な実装となります。(とはいえVoxelを扱うときに1Bit/Voxelの格納領域は、ほぼ必須な気もしますが。)

    // The following codes are for the 3Dtexture which is 8bit format.
    void MarkExFlag(RWTexture3D tExVoxel, const uint3 vPos)
    {
          uint3 exVoxPos = uint3(vPos.x/2, vPos.y/2, vPos.z/2);
          uint byteOfs = exVoxPos.x & 0x03;
          uint exFlag = 0x01 << ((byteOfs * 8) + (vPos.x&0x01) + (vPos.y&0x01) * 2 + (vPos.z&0x01) * 4);
         InterlockedOr(tExVoxel[uint3(exVoxPos.x&0xFFFFFFFC, exVoxPos.y, exVoxPos.z)], exFlag);
    }
    
    uint GetExFlag(Texture3D tExVoxel, const uint3 voxPos)
    {
        uint3  voxExPos = voxPos/2;
        uint  exVoxValue = tExVoxel.Load(uint4(voxExPos, 0));
        uint  flag = 0x01 << ((voxPos.x&0x01) + (voxPos.y&0x01) * 2 + (voxPos.z&0x01) * 4);
    
          return flag & exVoxValue;
    }
    

    フルスクリーン描画時のリフレッシュレート

    DirectXで、フルスクリーン描画をする際のディスプレイのリフレッシュレートについてです。

    最近のPC向けディスプレイ

    近年のPC向けディスプレイは、さまざまな垂直同期周波数を持っているケースがあります。具体的には、60Hzのほかに、120Hzや、144Hzなどです。
    これ等のリフレッシュレートは、垂直同期を有効にした場合でも、60Hzのディスプレイに比べ、高頻度に画面を書き換える機会を有しています。また、垂直同期を無効にした場合でも、同様に、高頻度に画面を書き換える機会を有しているため、ティアリングによる不快感を軽減(分断された絵の差分を最小限に)できる可能性があります。

    ゲーム製作者側の留意点

    ゲーム製作者側が留意しなくてはならないのは、当然ながら、垂直同期:有効=60Hzという前提でゲームを製作しないということです。これに関しては、殆どのPC向けのゲームでは、フレーム間の時間間隔は可変となっているので、問題になることは無いと思います。また、垂直同期を無効にしたケースではプログラム側は特に留意することは無いように思えます。
    ただし一点だけ気をつけたほうが良いことがあります。それは、フルスクリーン描画を行う際の、RefreshRateの設定値です。デバイス作成時に設定するRefreshRateは、そのままディスプレイの垂直同期周波数として設定され、D3DPRESENT_INTERVALがどのように設定されても、その値が使用されます。
    つまり、120Hzの垂直同期周波数をサポートするディスプレイでは、60Hz/120Hzいずれにも設定して、垂直同期を無効にしてフルスクリーン描画を行うことができます。その際に観測されるFPSは、基本的には全く差が無い状態となりますが、ディスプレイの駆動周波数は異なるため、プレイヤーの体験としては違うものになってしまいます。
    では、どうすれば良いかですが、現在のディスプレイのRefreshRateを取得し、同一のRefreshRateで使用可能なスクリーン解像度を使用すると良いと思います。また、ゲーム内のメニューから明示的に設定できるようにしても良いと思います。

    初期化の例

    以下の例では、現在設定されているRefreshRateを尊重しつつ、19×10解像度のフルスクリーン描画の初期化を行うコードです。解像度が存在しない場合の処理などは省略されています。

    if( NULL == ( g_pD3D = Direct3DCreate9( D3D_SDK_VERSION ) ) )
      return E_FAIL;
    
    D3DDISPLAYMODE curDM;
    g_pD3D->GetAdapterDisplayMode(D3DADAPTER_DEFAULT, &curDM);
    
    D3DDISPLAYMODE targetDM;
    UINT targetRes[2] = {1920, 1080};
    
    ZeroMemory(&targetDM, sizeof(targetDM));
    
    // check if there is 19x10 resolution with current refresh rate and color depth.
    UINT cnt = g_pD3D->GetAdapterModeCount(D3DADAPTER_DEFAULT,
          curDM.Format);
    for (UINT i=0; iEnumAdapterModes(D3DADAPTER_DEFAULT,
      curDM.Format, i, &dm);
    
      if (curDM.Format == dm.Format &&
          curDM.RefreshRate == dm.RefreshRate &&
          targetRes[0] == dm.Width &&
          targetRes[1] == dm.Height) {
        targetDM = dm;
        break;
      }
    }
    
    // you'll need proper handler...
    if (targetDM.Width != targetRes[0])
      return E_FAIL;
    
    static D3DPRESENT_PARAMETERS d3dpp = {
      targetDM.Width, 
      targetDM.Height,
      targetDM.Format,
      2,
      D3DMULTISAMPLE_NONE,
      0,
      D3DSWAPEFFECT_DISCARD,
      hWnd,
      0, //Full screen
      0,
      D3DFMT_UNKNOWN,
      0,
      targetDM.RefreshRate, // set the refresh rate that have been set.
       //D3DPRESENT_INTERVAL_ONE // enable v-sync
      D3DPRESENT_INTERVAL_IMMEDIATE // disable v-sync
    };
    
    // create a device.
    if( FAILED( g_pD3D->CreateDevice( D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd,
     D3DCREATE_SOFTWARE_VERTEXPROCESSING,
     &d3dpp, &g_pd3dDevice ) ) )
     

    DrawCallが9倍早くなるワケ

    地表で生きるものとして、Windows上でのDrawCallの仕組みを見てみます。

    DrawCallがCPU処理時間を消費する理由

    DrawCallがプロセス側から発行されると、その時点で、描画に使用するリソース(VB,IB,Tex,CBなど)が、存在するかどうか、大きさは適切であるかなどの、妥当性のチェックが行われると同時に、リソースがGPU側のデバイスメモリに転送されているか、転送されていればそのアドレスはどこなのか、などの問題が解決され、GPU用の描画命令コマンドとして、専用のバッファに蓄積されます。
    ここまでが、大まかですが、DrawCallの発行によって、CPU側で処理される内容です。
    リソースの生成を伴わない、通常のDrawCallであれば、専用のバッファへの蓄積までは、UMD(User Mode Driver)で処理されます。UMDは、皆さんのプロセスと同じアドレス空間を用いて動作するもので、要はDLLのライブラリ等と同じです。KMDはこのケースでは動作することは殆どありません。(KMDについては後述)
    では、何がCPU処理時間を消費しているのでしょうか。
    上記で述べた、使用リソースの妥当性チェックと、リソースのオブジェクト名からアドレスへの変換と、API呼び出しのオーバーヘッドです。

    ちなみに、DXでは、DXのAPIが呼び出されると、DXのレイヤーの処理に加え、必要に応じてそれに対応するUMDの関数が呼び出されますが、OGLの場合は、その処理のほとんどが、UMDに相当するレイヤーで行われます。従って、OGLの方が、全般的にAPIレイヤーのオーバーヘッドが少ないと思われます。
    下記のリンクは、この違いが、実際の性能に現れたケースだと思われます。
    [EXTREME TECH: Valve: OpenGL is faster than DirectX — even on Windows]
    ただし、全てにおいてDXよりOGLの方が速いとは限りません。DXは多数のゲームタイトルで使用され、最適化も進んでいます。加えてOGLのように太古の昔からの互換性もありません。

    KMDについて

    私はWindowsのドライバの中を見たことも書いたことが無いので、以下は正確性を欠いているかもしれません。
    KMDはKernel Mode Driverで、その名の通り、KernelModeで動作しているものです。KMDのインスタンスはシステム上で1つのみロードされており、GPUとの直接の通信を担当します。
    対してUMDはプロセスごとにロードされるため、使用しているプロセスごとに、複数個存在する前提となっています。
    KMDはGPUとの唯一の窓口であるため、GPU側のメモリの管理や、Apertureと呼ばれる、GPU側から見えるHOST側のメモリの管理を行っています。
    KMDはUMDの要求に応じて、GPUのメモリやApertureの領域を割り当てます。UMDはそれを、自分のリソースとして管理しているため、GPU側の、自分の保持しているリソースのアドレスを知ることが出来ます。GPU側のメモリやAperture領域の確保は、UMD-KMD間のやり取りを必要とするため、非常にコストが高いです。
    従って、小さなサイズのリソースに関しては、UMDがある程度の大きさでKMDよりメモリ空間を取得し、それをUMD側で分割して割り当てていることがあります。
    つまり、グラフィックスAPI上で新規にリソースを作成したからといって、直ちにKMDでGPU側のメモリ確保を行っているとは限りません。
    もうひとつKMDの大きな役割として、GPU処理命令のブロック(DMAパケット)のGPU側への送出があります。DMAパケットの生成の殆どはUMD側で行いますが、実際にGPUに送る作業はKMDが行います。KMDはGPUを使用するプロセスが複数存在する場合は、そのスケジューリングも行います。スケジューリングの粒度は、このDMAパケットになります。
    ひとつのDMAパケットがどの程度のGPU処理時間を使用するかは分からないので、プロセス間でGPUの競合が起きた場合の切り替わりの速度は、このDMAパケットの処理時間に依存します。
    このあたりの事情はWDDMが2.xになると大きく変わると思われますが、Windows8.1でもWDDMは1.3なので、今でも大枠でこの仕組みは変わっていないと思われます。
    また、ディスプレイの解像度変更など、システム全体に影響のある処理はKMDが担当しています。

    話を元に戻します

    OGLでDrawCallのCPU処理時間について詳細に扱ったセッションが、GTC2013でありました。
    [GTC2013 Advanced Scenegraph Rendering Pipeline]

    このスライドの30ページの所に注目です。2400回のDrawCallのCPU側の処理時間を比較しています。
    VBOと示されるのは、通常の頂点バッファを用いたDrawCallです。これを基準として考えます。
    VABは頂点フォーマットの設定をオブジェクト化してBindできるようにしたものです。OGLでは通常は、頂点フォーマットの設定は多数のGL関数の呼び出しを伴いますが、これを用いると、頂点フォーマットの設定をオブジェクト化でき、一度のAPI呼び出しで設定できるようになります。DXで言うところの、FVFからCreateVertexDeclaration()への変更といったところでしょうか。これによって処理時間は、10%ほど短縮されています。
    VBUMは、頂点バッファのアドレスを、プロセス側で先に取得しておき、DrawCallの発行時には、GPU上のアドレスを直接指定する方法です。これにより、DrawCallごとに発生していた、頂点バッファのオブジェクト名からGPU上のアドレスへの変換作業が省略することができ、50%以上CPU処理時間が短縮されています。この短縮された時間には、アドレス変換だけではなく、リソースの妥当性検証部分も含まれていると思われます。なぜならば、GPU側のアドレスを直接指定するため、UMDレベルでは、リソースの妥当性検証は不可能に近いので、行われていないと考えるのが自然でだからです。
    BINDLESS INDIRECT HOSTは、CPU側で、頂点バッファと頂点フォーマットに相当するアドレス情報と、インデックスバッファのアドレスを指定した、DrawCallに相当する構造体の配列を作成し、これにより複数のDrawCallを一回のAPI呼び出しで処理するものです。これを用いることで90%近い処理時間の短縮が達成されています。
    つまり、通常の手順でDrawCallを呼び出す代わりに、DrawCallの使用するリソースの指定にGPU側のアドレスを直接指定し、一時的なバッファに描画命令を蓄積して、DrawCallをある程度まとめて発行すれば、DrawCallの処理が9倍早くなるのも妥当だと思われます。
    BINDLESS INDIRECT GPUは、DrawCallの内容を指定した構造体配列が、GPU側のメモリに格納されているケースです。こちらは、もはやCPUの処理時間を殆ど消費しません。
    このように既存のAPIである、OpenGLでも、GPUベンダーによる拡張を使用することを前提にすると、Textureなどの他のリソースも殆どがGPU側のアドレスを取得でき、描画リソースを指定する際に使用できるようになっています。これ等を積極的に用いると、DrawCallのCPU側の処理時間を数倍オーダーで高速化することは十分可能です。