Metalのレイトレーシング事情

これはレイトレ合宿7アドベントカレンダーの記事です。

容赦無い数式が名物とも言えるレイトレ合宿アドベントカレンダーですが、レイトレ合宿の面白いところには人様の書いたレイトレーサーの実装を垣間見られるというものもあります。

そこで今回はMetal Performance Shaderレイトレーシングの話です。 Appleが絶賛実装中のGPUレイトレーサーをささっと見てみようと思います。

Metal Performance Shader

ところでMetalがAppleのグラフィックスAPIなのは知っているけどPerfomance Shaderとは、という方もいると思うので軽く解説します。

超ざっくり言うとApple謹製のGPGPU関連のボイラープレートコード集です。

御多分に洩れずMetalでも一発コンピュートシェーダーを走らせたいだけでもそこそこな量の前準備が必要になります。 しかもだいたい同じようなことを何度も書くためにコピペして、変えなくてはいけない変数や数値を変え忘れて時間を無駄にしたりするわけです。

そんなことをしているとこの辺誰か詳しい人がいい感じにまとめてくれると嬉しいのに、なんて思ったりします。 そこでなんと掛け値無しに一番詳しいAppleさんがまとめてくれたのがMetal Performance Shaderです。

以下面倒臭いのでMPSと略します。

MPSのレイトレーシングNVIDIA RTXとDirectXのDXRが電撃的に発表された直後のWWDC2018で追加されました。 Metal Performance ShaderにあるRay Tracingという項目がそれです。 とはいえAppleも対抗したかったのか急いで出してきたようで、いまだにwebのドキュメントはほとんど空白で詳しい解説はヘッダーに書いてある有様です。

できること

MPSのレイトレーシングでできることは三角メッシュで構築されたシーンとレイとの交差判定です。インスタンシングもできます。

レイを詰め込んだバッファーを入れると結果を入れたいバッファが交差判定の結果で埋まるという塩梅です。 それをGPUでやってくれます。

交差判定の結果をどう料理するかはこちらで好きなようにMetalのコンピュートシェーダーを書くことになります。 反射するレイを生成するとか、ライトをサンプリングするとか、フォトンを探索するとかご自由に、という感じです。

Metalで実装されたEmbreeに近いものと言えるかもしれません。

Ray Tracing

さて、ではどのようなものが用意されているかといえばmacOS Mojaveでは以下のようなものです。

たった5つのクラス、しかもMPSAccelerationStructureはベースクラスで、MPSAccelerationStructureGroupはインスタンシング用のグループを定義するクラスです。 つまり実質3つのクラスしかありません。

思いの外こじんまりとした印象かもしれませんが、それぞれの用途はクラス名を見ればなんとなく想像できると思います。

AccelerationStructure

  • MPSTriangleAccelerationStructure
  • MPSInstanceAccelerationStructure

というわけでまずはこの2つ。 その名の通り三角形とインスタンスのAccelerationStructureです。

一番簡単な使い方はMPSTriangleAccelerationStructureだけを使うものでしょう。 最近まで唯一のサンプルコードだったMetal for Accelerating Ray Tracingもそのような使い方のサンプルです。

accelerationStructure = [[MPSTriangleAccelerationStructure alloc] initWithDevice:device];

accelerationStructure.vertexBuffer = vertexBuffer;
accelerationStructure.triangleCount = triangleCount;

// 頂点インデックスがあるなら
accelerationStructure.indexBuffer = indexBuffer;
// トライアングルのマスクを使うなら
accelerationStructure.maskBuffer = maskBuffer;

[accelerationStructure rebuild];

こんな感じになるでしょうか。 もちろんそれぞれのバッファのoffsetstrideといったお馴染みのプロパティもありますのでお好みなデータ構造を使えます。

馴染みが無いのはmaskBufferです。 これは各三角形に割り当てたビットフラグです。したがって長さはtriangleCountと同じになります。

後述のレイの構造体にもmaskというフィールドが存在するものがあり、この値とANDをとって0でなければ交差判定をする、というものだそうです。

#define TRIANGLE_MASK_GEOMETRY 1
#define TRIANGLE_MASK_LIGHT    2

#define RAY_MASK_PRIMARY   3
#define RAY_MASK_SHADOW    1
#define RAY_MASK_SECONDARY 1

上記のサンプルの ShaderTypes.h にはこんな定義があります。 このサンプルコードは光源を明示的にサンプルしていく実装になっているので、最初のカメラからのレイ以外は光源を無視するようにマスクを設定しています。

MPSInstanceAccelerationStructureを使う場合にはMPSAccelerationStructureGroupでAccelerationStructureをグループ化します。

// グループの用意
group = [[MPSAccelerationStructureGroup alloc] initWithDevice:device];

// InstanceAccelerationStructureで扱うTriangleAccelerationStructureは1つのvertexBufferを共有していなければならない
totalVertexCount = 0;
for (SceneObject *object in sceneObjects)
    totalVertexCount += object.vertexCount;

vertexBuffer = [_device newBufferWithLength:totalVertexCount * sizeof(Vertex) options:options];
// vertex以外のバッファもあれば同様に
...

// まずはインスタンスの元になるオブジェクトのMPSTriangleAccelerationStructureをつくる
vertexOffset = 0;
for (SceneObject *object in sceneObjects) {
    accelerationStructure = [[MPSTriangleAccelerationStructure alloc] initWithGroup:group];

    // bufferのoffsetでどうにかする
    accelerationStructure.vertexBuffer = vertexBuffer;
    accelerationStructure.vertexBufferOffset = vertexOffset * sizeof(Vertex);
    accelerationStructure.triangleCount = object.triangleCount;
    ...
    vertexOffset += object.vertexCount;

    [accelerationStructure rebuild];

    [triangleAccelerationStructures addObject:accelerationStructure];
}

// MPSInstanceAccelerationStructureをつくる
instanceAccelerationStructure = [[MPSInstanceAccelerationStructure alloc] initWithGroup:group];
// インスタンス元のMPSTriangleAccelerationStructureのarray
instanceAccelerationStructure.accelerationStructures = triangleAccelerationStructures;
// 置きたいMPSTriangleAccelerationStructureのインデックスを並べたバッファ
instanceAccelerationStructure.instanceBuffer = instanceBuffer;
instanceAccelerationStructure.instanceCount = numInstances;
// インスタンスの位置を指定したければ
instanceAccelerationStructure.transformType = MPSTransformTypeFloat4x4;
instanceAccelerationStructure.transformBuffer = instanceTransformBuffer;
// マスクを使いたければ
instanceAccelerationStructure.maskBuffer = instanceMaskBuffer;

[instanceAccelerationStructure rebuild]

instanceBufferはシーン中にインスタンスとして置きたいMPSTriangleAccelerationStructureのインデックスです。 transformBufferと対応した位置に置かれます。 transformTypeMPSTransformTypeIdentityMPSTransformTypeFloat4x4の2種類で、単位行列しか使わない場合はトランスフォームの処理をスキップするためのもののようです。

ちなみにAccelerationStructurerebuildをしないと使えません。いくつか構築する時のオプションなどがあります。

// MPSAccelerationStructureのusageプロパティで使用するenum
MPSAccelerationStructureUsageNone   // 特に指定なし
MPSAccelerationStructureUsageRefit  // 変化があっても基本的にリフィットで対応する
MPSAccelerationStructureUsageFrequentRebuild    // 頻繁にリビルドしたい

まずusageに設定するenumがあります。 これはAccelerationStructureの構築アルゴリズムを決めるためのもので、 MPSAccelerationStructureUsageRefitは構築に時間がかかるがレイの交差判定が高速になるアルゴリズムを使い、MPSAccelerationStructureUsageFrequentRebuildでは構築は早いものの交差判定のパフォーマンスが落ちるアルゴリズムが使われるらしいです。 なお無指定のMPSAccelerationStructureUsageNoneの扱いは不明です。

// その場で構築
- (void)rebuild;

// 並列で構築して終わったらcompletionHandlerを呼び出す
- (void)rebuildWithCompletionHandler:(nonnull MPSAccelerationStructureCompletionHandler)completionHandler;

// リフィットコマンドをコマンドバッファーに積む
- (void)encodeRefitToCommandBuffer:(nonnull id <MTLCommandBuffer>)commandBuffer;

それに対応してリビルドとリフィットのメソッドがあります。 見てわかるようにリフィットはGPUで実行されます。 もちろんリフィットは一度rebuildしないと使えません。

これを利用して並列で再構築している最中はリフィットして、リビルドが終わったら構築済みのものと差し替える、というAccelerationStructureのダブルバッファリングが MPSAccelerationStructure.h のコメント内で提案されていたりします。

それからMPSAccelerationStructureNSSecureCodingプロトコルを実装しているのでNSKeyedArchiverシリアライズが可能です。 普通にplistが出てきます。中を見るとBVHなんとかみたいなものが見えるのでBVHの一種のようです。

静的なシーンはMPSAccelerationStructureUsageRefitで構築して保存して使うと良いと思うぜ 、とコメントの人は書いています。

MPSRayIntersector

次はMPSRayIntersectorです。 文字通りレイとMPSAccelerationStructureとの交差判定をとります。

// MPSRayIntersectorの作成
intersector = [[MPSRayIntersector alloc] initWithDevice:device];
// レイのデータ構造のタイプ
intersector.rayDataType = MPSRayDataTypeOriginMaskDirectionMaxDistance;
intersector.rayStride = rayStride;
// 使いたいマスクのフラグ
intersector.rayMaskOptions = MPSRayMaskOptionPrimitive;

// レイを詰め込むバッファーを用意
rayBuffer = [device newBufferWithLength:sizeof(Ray) * rayCount options:options];

// 交差判定の結果が入るバッファーを用意
intersectionBuffer = [device newBufferWithLength:sizeof(Intersection) * rayCount options:0];

id <MTLCommandBuffer> commandBuffer = [commandQueue commandBuffer];

// 交差判定の結果の構造体のデータタイプ
intersector.intersectionDataType = MPSIntersectionDataTypeDistancePrimitiveIndexCoordinates;

// レイトレース
[intersector encodeIntersectionToCommandBuffer:commandBuffer
                              intersectionType:MPSIntersectionTypeNearest
                                     rayBuffer:rayBuffer
                               rayBufferOffset:0
                            intersectionBuffer:intersectionBuffer
                      intersectionBufferOffset:0
                                      rayCount:rayCount
                         accelerationStructure:accelerationStructure];

[commandBuffer commit];

基本的な流れはこんな感じです。 rayDataTypeintersectionDataTypeはいくつか用意されているレイと判定結果の構造体のタイプを指定します。

// MPSRayDataType
MPSRayDataTypeOriginDirection
MPSRayDataTypeOriginMinDistanceDirectionMaxDistance
MPSRayDataTypeOriginMaskDirectionMaxDistance

// MPSIntersectionDataType
MPSIntersectionDataTypeDistance
MPSIntersectionDataTypeDistancePrimitiveIndex
MPSIntersectionDataTypeDistancePrimitiveIndexCoordinates
MPSIntersectionDataTypeDistancePrimitiveIndexInstanceIndex
MPSIntersectionDataTypeDistancePrimitiveIndexInstanceIndexCoordinates

それぞれはこんな えらく長い名前の enumが定義されていて、それぞれの説明には例えばMPSRayDataTypeOriginDirectionには Use the MPSRayOriginDirection struct type というような説明になっていない説明のコメントがついています。

実はそれらの構造体の定義は MPSRayIntersectorTypes.h にあって、 MetalPerformanceShaders/MetalPerformanceShaders.h をincludeすることでMetalのシェーダーと共用で使えるようになっています。

交差判定はコマンドバッファーにコマンドを積んで、rayBufferに詰めたレイの判定結果が実行後に対応する位置のintersectionBufferに入ります。

前述の通りあくまで交差判定の結果なので、そこから反射するレイを生成したり、光源へシャドウレイを飛ばしたりといった処理をするには自分でコンピュートシェーダーを用意する必要があります。

とはいえここから先は

  • intersectionBufferの内容からrayBufferを新しいレイで埋めるシェーダーをエンコード
  • [intersecter encodeIntersectionToCommandBuffer:...]

という流れを繰り返すだけで、GPU上でレイトレーシングをすることができます。

実際の流れは上にも挙げたMetal for Accelerating Ray Tracingを見るのが手っ取り早いと思います。

意外と面倒くさいです。

ちなみにMPSRayIntersectorのヘッダーにはかなり長い説明のコメントが書いてあります。 基本的な使い方からパフォーマンスに関するアドバイス的なことまであり、いくつかピックアップしてみると、

  • レイや交差判定の構造体は小さいタイプを選んだ方がパフォーマンスが良い
  • インデックスバッファーやマスクバッファーは使わない方がパフォーマンスが良い
  • メモリが許すなら全部三角形に展開してしまった方がパフォーマンスが良い
  • わざわざray bufferを縮める処理をするより不正なレイ(たとえば最大距離を負の値にしたり方向ベクトルをゼロにする)を突っ込んだ方がパフォーマンスが良い

などというまあそうでしょうけどみたいなことが書いてあって面白いです。

これから

6月にWWDC2019がありました。

次のバージョンのmacOSiOSが発表されて、 意外にも MPSのレイトレーシングにも少し改良が予定されています。

  1. MPSAccelerationStructureGPUrebuildできるようになる
  2. MPSPolygonAccelerationStructureが追加されMPSTriangleAccelerationStructureはそのサブクラスとなる。またMPSQuadrilateralAccelerationStructureが追加される。
  3. rayBufferの代わりにrayTextureが使えるメソッドが追加される
  4. rayIndexBufferというバッファを使った交差判定メソッドが追加される

ざっと見て気になったものを挙げてみるとこんなところでしょうか。

1は文字通りで、コマンドバッファにrebuild相当のものを積むメソッドが追加されます。 ダブルバッファリングしなくて済むくらい高速なのでしょうか。

2は三角形以外も扱えるようにしたいということでしょう。早速四辺形が追加されています。SceneKitはopenSubDivのテッセレーションを使っているのを売りにしているので、ゆくゆくはCatmul-Clark細分割曲面をレイトレースしたいのかもしれません。

3はラスタライザとのハイブリッドレンダリングをしやすくする改良のようです。新しいサンプルコードAnimating and Denoising a Raytraced Sceneがまさにそのハイブリッドレンダリングのサンプルで、ラスタライザのマルチレンダーターゲットを使ってレイテクスチャを作ってMPSのレイトレーシングに使うというものになっています。

4は 今までrayBufferを縮めるより不正なレイを入れた方が良いと言っていたが我々はより良いソリューションを見つけ出した というやつです。インデックスだけ詰め直して縮めれば良いんじゃないということらしいです。

それからどこに分類されるのかよくわかりませんが、動画の前のフレームの情報を使うタイプのフィルタが追加されています。

  • MPSTemporalAA
  • MPSSVGF

TemporalAAは最近のゲームではお馴染みのアンチエイリアスフィルタでしょう。

SVGFはSpatiotemporal Variance- Guided Filteringのことで、デモ動画は衝撃的でした。 そしてこれを使うMPSSVGFDenoiserというデノイザーのクラスも追加されています。

RTXなんてなくてもリアルタイムGPUレイトレできらぁと言わんばかりの環境がmacOSiOSには整いつつあるようです。

次のバージョンのOSのリリースが楽しみになりますね。

参考