3Dシーンにおいて、最終的な描画結果に影響しないものを取り除き、描画処理を省略することを「カリング」といい、パフォーマンスの向上のために重要な概念です。その中のひとつ「オクルージョンカリング」は、視点から見ると他のオブジェクトに遮蔽(occlude)されて見えないオブジェクトをあらかじめ検知し、最初から描画しないことで、処理負荷を軽減しようという戦略です。

本記事で紹介するANY_SAMPLES_PASSEDは、WebGL 2.0で追加されたクエリ機能のひとつで、GPUベースで遮蔽されたオブジェクトを検知するのに役立つ情報を提供します。

ANY_SAMPLES_PASSEDを使用したデモの紹介

ANY_SAMPLES_PASSEDを使用するとどんなことができるか、ShrekShaoさんが作成したオクルージョンカリングのデモを紹介します。ShrekShaoさんは他にも、数多くのWebGL 2.0のデモを公開してくれています。

右下の情報はそれぞれ下記の内容をあらわしています。

  • Spheres: 描画対象の球の数です
  • Culled spheres: カリングされた球の数です。この項目が多いほどレンダリングを省略でき、恩恵が得られます
  • Enable occlusion culling: オクルージョンカリングの有効/無効を切り替えます
  • Top-down HUD: 3Dシーンを上からみた簡略図(画面左下)の表示有無を切り替えます

このデモは、36個の球を縦横6x6の正方形状に並べ、真横からみて回転させています。視点から見て後ろにある球は、手前の球に完全に遮蔽されるため、ANY_SAMPLES_PASSEDを使用して深度テストに失敗した球は描画を省略しています。画面左下のHUDを見ると、遮蔽された球は描画されていないことがわかります。球の数36に対して、常時22〜29の球を描画から外し、60%以上の不要な情報を省略できていることになります。

オクルージョンクエリを使ったオクルージョンカリングの戦略

オクルージョンカリングには、CPUで3Dシーン上の遮蔽を計算して取り除くCPUベースのものもありますが、今回紹介するのはGPUベースのカリングです。オクルージョンクエリには、(非同期のため、検知に遅延が生じますが、)CPUの計算負荷がかからないメリットがあります。

EXT_disjoint_timer_query_webgl2の記事で以前紹介しましたが、クエリ(WebGLQuery)とは、WebGL 2.0の機能で、GPU内部で起こったなんらかの情報を問い合わせます。今回紹介するANY_SAMPLES_PASSEDは、計測期間中にすべてのピクセルが深度テストに失敗したかどうかをGPUから非同期に取得できます。

深度テストとは、深度バッファを使用してオブジェクトの前後関係を解決する機能です。深度バッファは描画するスクリーンと同じ大きさの領域で、オブジェクトを描画するたびに描画されたオブジェクトのピクセルごとの深度値(z座標)をここに記録します。新たに上からオブジェクトを描画する際には、描画しようとしているピクセルに注目し、現在の深度値と、描こうとしているオブジェクトの深度値を比較(テスト)して手前にある場合にのみ描画を行います。

深度テストは3Dレンダリングではかならずといっていいほど使用される重要な概念で、この機能によって、後ろにあるオブジェクトを後から描画した場合でも、前のオブジェクトからはみ出た部分のみ正しく描画できます。また、オブジェクト同士が交差して、ある部分では前面に、ある部分では背面にある、といった複雑な関係のオブジェクトも表現できます。

あるオブジェクトを描画したとき、描画スクリーン上でそのオブジェクトのすべてのピクセルで深度テストに失敗したことはなにを意味するでしょうか? すべてのピクセルについて手前にすでに描画されたオブジェクトがあり、テストに失敗して1ピクセルもそのオブジェクトは描画されなかった、つまり、そのオブジェクトはまったく描画されなかったことを意味します。描画されないオブジェクトについてレンダリング(ドローコールの発行やシェーダーの処理など)を行うことはムダです。ANY_SAMPLES_PASSEDすべてのピクセルが深度テストに失敗したかどうかがわかれば、そのオブジェクトはレンダリングから外してしまおう、というのがオクルージョンクエリを使用したオクルージョンカリングの戦略です。

ANY_SAMPLES_PASSEDの挙動を理解するサンプルの紹介

ANY_SAMPLES_PASSEDを使ったかんたんなサンプルを紹介します。

このサンプルでは、(1)赤、(2)緑、(3)青の順番に四角形を描画し、それぞれの描画コマンドの前後でANY_SAMPLES_PASSEDクエリを計測しています。各色の四角形を描画した際のクエリ結果は左上に表示され、1であればその四角形の少なくとも1ピクセル以上が深度テストに成功したことをあらわしています。

緑の四角形はマウスに追従するので、赤や青の四角と重ねることでANY_SAMPLES_PASSEDがどのようにはたらくか確認できます。もう1つ注目するべき点として、各四角形のz座標があります。z座標は0が一番手前、1が一番奥となっており、赤:0.0、緑:0.5、青:0.2が設定されているので、深度テストを有効にした場合、描画後の重なり順は赤→青→緑となります。まずは本当にそうなっているか確認しましょう。

※サンプルでは緑の四角が青の四角に完全に隠れているとき、緑のANY_SAMPLES_PASSED1になることに違和感があるかもしれません。これは描画する順番が影響しており、APIの仕様上は正しい挙動です。詳しい理由については後述します。

深度テストを理解する

描画順が赤→緑→青であるのに、見た目の重なり順は赤→青→緑となるのは、深度テストのおかげです。描画の流れを追っていきましょう。

まず、なにもないキャンバスに (1)赤が描画されます。このとき、深度バッファの値は描画ループの最初にすべてのピクセルについて1.0で初期化してあるので、1.0より小さい深度0.0をもつ赤い四角はすべてのピクセルで深度テストに成功し、描画されます。同時に、赤い四角が描かれた領域の深度バッファのピクセルは0.0になります。

次に緑を上から描画しますが、赤と緑が重なっていない領域に関しては赤と同じ挙動になり、緑が描かれ、深度バッファは0.5になります。先に赤が描かれていた領域では、深度バッファに格納されている値0.0よりも新たに描こうとしている緑の深度値0.5のほうが大きいため、深度テストに失敗し、描画は行われません。深度バッファも更新されず、0.0のままです。

最後に青を上から描画するとき、赤とも緑とも重なっていない領域は青が描かれ、深度バッファは0.2になります。先に赤のみが描かれていた領域では、深度バッファに格納されている値0.0よりも新たに描こうとしている青の深度値0.2のほうが大きいため、深度テストに失敗し、描画は行われません。深度バッファも更新されず、0.0のままです。緑のみが描かれている領域に描く場合は、深度バッファに格納されている値0.5よりも新たに描こうとしている青の深度値0.2のほうが小さいため、深度テストに成功して、緑は青に塗りつぶされます。

ANY_SAMPLES_PASSEDの挙動を理解する

さて、このとき、ANY_SAMPLES_PASSEDの値はどうなるでしょうか? 赤は最初に描画するとき、すべてのピクセルで深度テストに成功するため、ANY_SAMPLES_PASSEDの結果はかならず1となります。緑は、赤の四角に完全に覆われて見えなくなる状態では、ANY_SAMPLES_PASSED0となり、少しでも赤い四角からはみ出た場合は1となります。青についても、赤の四角に完全に覆われて見えなくなる状態では0となり、少しでも赤い四角からはみ出た場合は1となります。赤と青の四角はサンプルの初期状態では同じサイズですが、画面右のスライダーを操作すればサイズや位置を変更できるので、完全に赤に覆われた場合の挙動も確認ができます。

ここで少しややこしいのが、緑と青の関係です。青は緑より手前にある(zが小さい)ため、青と緑が重なっていても深度テストに失敗することはなく、ANY_SAMPLES_PASSED1になります。一方、緑は青に完全に覆われて見えない状態であっても、ANY_SAMPLES_PASSED1のままです。青い四角に関係なく、赤い四角との関係でのみANY_SAMPLES_PASSEDが変化します。

これはなぜかというと、ANY_SAMPLES_PASSEDがあらわすのは「緑の四角が(完全に)遮蔽されているかどうか」ではなく、「緑の四角のクエリ計測時に(全ピクセル)深度テストに失敗しているかどうか」だからです。緑の四角を描画するタイミングでは青はまだ描画されておらず、したがって青が緑よりも手前にあろうとも、緑が深度テストを失敗する原因にはならないということです。

このことは、ANY_SAMPLES_PASSEDを遮蔽の判定に使おうとした場合、描画の順番が大事になってくるということです。オブジェクトのzが小さい順にあらかじめソートし、その順番に描画しないと、深度テストに失敗したから遮蔽されていると判断できなくなります(そもそも深度テストに失敗しなくなります)。最初のデモでも、毎フレーム深度によるソートをCPUで行い、視点から近い順にレンダリングしています。

※もともと、不透明のオブジェクトを近い順にソートして描画し、その後、半透明のオブジェクトを遠い順にソートして描画というのが描画順のセオリーですが

※シーンによっては、オブジェクトの形状やシェーダーを単純化してレンダリングを2周行うことで、最終描画結果とオブジェクトごとの深度テスト成否を一致させる戦略も有効かもしれません

zの値も含めスライダーで操作できるようになっているので、いろいろなパターンを試して挙動の結果イメージを掴んでください。

APIの使い方

まずはクエリオブジェクトについておさらいします。クエリオブジェクトの基本的な使い方は、EXT_disjoint_timer_query_webgl2の記事で以前紹介していますので、こちらもあわせて参照ください。

最初にWebGL 2.0のコンテキストを取得します。以下gl2は、ここで取得したWebGL2RenderingContextオブジェクトを指します。

// WebGL2コンテキスト(WebGL2RenderingContext)を取得
const gl2 = canvas.getContext('webgl2');

WebGLQueryの使い方

まずはWebGLQueryについて説明します。クエリオブジェクトはWebGL2RenderingContextから作成します。

// WebGLQueryを作成
const query = gl2.createQuery();

計測したいWebGLコマンド(今回は描画命令)呼び出しの前後でbeginQuery()メソッドおよびendQuery()メソッドを実行します。第一引数targetには計測したい対象の項目を指定します。今回の場合、gl2.ANY_SAMPLES_PASSEDです。

// 計測を開始
gl2.beginQuery(target, query);

// 計測するWebGLコマンド
...

// 計測を終了
gl2.endQuery(target);

クエリ結果を取得するにはgl2.QUERY_RESULTgl2.getQueryParameter()メソッドの第二引数にして呼び出します。ただし、クエリ結果を取得するには、クエリオブジェクトがクエリ結果取得可能状態になっている必要があります。クエリ結果取得可能状態はgl2.QUERY_RESULT_AVAILABLEgl2.getQueryParameter()メソッドの第二引数にして呼び出して取得します。この値がtrueであればクエリ結果を取得できます。

// クエリ結果が取得可能か確認
const resultAvailable = gl2.getQueryParameter(query, gl2.QUERY_RESULT_AVAILABLE);

if (resultAvailable) {
  // クエリ結果を取得
  const result = gl2.getQueryParameter(query, gl2.QUERY_RESULT);
}

また、以前のクエリオブジェクトの記事でも注意していますが、クエリ結果が取得できるのは、早くても次フレーム以降になります。そのため、ひとつの計測対象について、複数のクエリオブジェクトを用意します。まだ結果が取得できていないクエリオブジェクトは結果が取得可能になるまで退避しておいて、その間の計測には別のクエリオブジェクトを使用します。イメージとしては、下記の表のような時系列(縦に進むにつれて時間が経過)で複数のクエリオブジェクトを使用します。

クエリオブジェクト1 クエリオブジェクト2 クエリオブジェクト3 クエリオブジェクト4
フレーム1 作成・計測
フレーム2 待機 作成・計測
フレーム3 待機 待機 作成・計測
フレーム4 フレーム1の結果を取得 待機 待機 作成・計測
フレーム5 計測 フレーム2の結果を取得 待機 待機
フレーム6 待機 計測 フレーム3の結果を取得 待機
フレーム7 待機 待機 計測 フレーム4の結果を取得
フレーム8 フレーム5の結果を取得 待機 待機 計測

サンプルでは使用中のクエリオブジェクトのリスト(usingList)と使用可能なクエリオブジェクトのリスト(availableList)を作りました。計測時には使用可能なリストからクエリオブジェクトを取得し、なければ新たに作成します。計測が終わったら使用中のリストに追加します。結果を取得するときは使用中のリストのクエリオブジェクトの結果取得可能かをチェックし、結果を取得できたら使用可能なリストに戻します。

WebGLQueryを使用して複数フレームに渡って計測を行うコードをまとめると、下記のようになります。

▼ 計測時

// 使用可能なクエリをリストから取得(なければ新たに作成)
const availableQuery = availableList.length ? querySet.availableList.shift() : gl2.createQuery();

// ANY_SAMPLES_PASSEDの計測を開始
gl2.beginQuery(gl2.ANY_SAMPLES_PASSED, availableQuery);
    
// 計測するWebGL描画コマンド(今回は描画命令)
gl2.drawElements(/* ・・・略・・・ */);
   
// ANY_SAMPLES_PASSEDの計測を終了
gl2.endQuery(gl2.ANY_SAMPLES_PASSED);

// 使用中のクエリリストに移動
usingList.push(availableQuery);

▼ 結果取得時

// 使用中のクエリを取得
const usingQuery = usingList.length ? usingList[0] : null;

if (usingQuery) {
  // クエリ結果が取得可能か確認
  const resultAvailable = gl2.getQueryParameter(usingQuery, gl2.QUERY_RESULT_AVAILABLE);

  if (resultAvailable) {
    // クエリ結果を取得
    const result = gl2.getQueryParameter(usingQuery, gl2.QUERY_RESULT);
    
    // 使用が終わったクエリを使用可能リストに移動
    availableList.push(usingList.shift());
  }
}

ANY_SAMPLES_PASSEDクエリの使い方

今回の計測に使用するANY_SAMPLES_PASSEDはWebGL 2.0のコア機能なので、EXT_disjoint_timer_query_webgl2拡張のように拡張機能を有効にする必要はありません。WebGL 2.0が有効なブラウザ環境であれば使用できます。

深度テストに成功したかどうかを計測するため、まずは深度テスト機能を有効にすることを忘れないようにします。WebGLでは、初期状態では深度テストは有効化されていません。

// 深度テストを有効にする
gl2.enable(gl2.DEPTH_TEST);

// 深度テストの評価方法をLEQUALに設定する
// これは、書き込もうとしているピクセルの深度値が、深度バッファの値よりも小さい(以下)場合に成功する
gl2.depthFunc(gl2.LEQUAL);

また、WebGL2コンテキストを取得するときも、深度バッファが必要なことは覚えておいてください。こちらは初期値(第2引数になにも渡さない場合)にはtrue(有効)なので、あえて明示的に設定する必要はありませんが、これがfalseになっていると深度テストはできません。

// WebGL2コンテキスト(WebGL2RenderingContext)を取得
// 深度バッファを有効にする(第2引数は渡さなくても深度バッファは有効になっている)
const gl2 = canvas.getContext('webgl2', { depth: true });

今回計測するのは描画コマンドなので、drawElements()メソッドやdrawArrays()メソッドの前後をbeginQuery()endQuery()で囲みます。

// 深度テスト成功状況の計測開始
gl2.beginQuery(ext.ANY_SAMPLES_PASSED, query);

// 描画コマンド
gl2.drawElements(/* ・・・略・・・ */);

// 深度テスト成功状況の計測終了
gl2.endQuery(ext.ANY_SAMPLES_PASSED);

計測結果を取得する際のgetQueryParameter()(第2引数にgl2.QUERY_RESULTを使用)の返却値は数値です。EXT_disjoint_timer_query_webgl2拡張の場合にはGPU時間がナノ秒で取得できましたが、ANY_SAMPLES_PASSEDの場合に取得できるのは01の数値です。0であればすべてのピクセルが深度テストに失敗1であれば深度テストに成功したピクセルが少なくとも1つあることをあらわしています。

EXT_disjoint_timer_query_webgl2拡張との違いをまとめると、下記の3点です。

  1. 拡張機能の有効化は不要(WebGL 2.0が有効であれば使用可能)
  2. disjoint operationが発生していないかのチェック(ext.GPU_DISJOINT_EXT)は不要
  3. 計測結果は0か1の数値で、深度テストに成功したピクセルが1つでもあれば1。それ以外の場合は0

以上でWebGLQueryおよびANY_SAMPLES_PASSED拡張の使い方を説明しました。深度テストの結果を使用してオクルージョンカリングに挑戦してみましょう。

補足

最初に紹介したデモに関していくつか説明できていない点があるので補足します。

遅延には目をつむる

WebGLQueryの仕様として、計測フレームでは結果を取得できず、数フレーム遅延が発生することを何度か説明してきました。これはANY_SAMPLES_PASSEDでももちろん発生します。そうすると、クエリ結果を取得して球が他の球に遮蔽されたという情報は、数フレーム前の状態ということになります。その後視点やオブジェクトが移動して本当は見える状態になっていたとしても、クエリ結果を元にカリングしてしまうこともありえます。これに関しては数フレームの遅延なので、フレームレートが30FPSを超えるような場合には気づきにくく、大きな問題にはならないというスタンスです。逆に本当は見えていない状態なのに最新のクエリ結果が可視となるためレンダリングすることもあります。こちらは深度テストに失敗して結果的に描画されないため、カリングには失敗しますが最終描画結果にはまったく問題ありませんね。

描画しなかった球は代わりにバウンディングボックスを描画する

クエリ結果で不可視と判定して球をカリングした場合、その球に関してレンダリングを行わないため、そのフレームで深度テストも発生せず、新しい遮蔽情報が得られなくなります。そこで、球のかわりに、球の描画領域を覆う単純な直方体のオブジェクト(バウンディングボックス)をレンダリングします。バウンディングボックスを可視/不可視の判定に使用すると精度は偽陽性方向に少し荒くなりますが、ポリゴン数は複雑なオブジェクトと比べ激減します。また、実際に描画をするわけでもないので、フラグメントシェーダーで行うテクスチャサンプリングなどの複雑な処理はすべてカットできます。単純に単色を塗るだけのシェーダーでいいわけです。

バウンディングボックスをレンダリングするとき、実際に描画をしたいわけではありません。深度テストさえ行われればいいわけで、万が一深度テストに成功してしまうと無意味な直方体が描画されてしまいます。これを避けるためにカラーバッファ(実際に見える描画結果)への書き込みをマスク(無効化)します。同時に、深度バッファが直方体の形状に更新されても困るので、深度バッファへの書き込みもマスクします。WebGLには描画命令時に各バッファへの書き込みを無効化する命令があるので、これを使用します。下記の設定でバウンディングボックスの描画命令を行うと、深度テストのみ実行され、数フレーム後にバウンディングボックスの深度テスト結果が取得できます。

// カラーバッファへの書き込みをマスクする
gl2.colorMask(false, false, false, false);

// 深度バッファへの書き込みをマスクする
gl2.depthMask(false);

ソースコードをみると、このデモでは、毎フレームまずはすべての球についてバウンディングボックスを描画し、前フレームのクエリ結果で遮蔽されていないと判断した球のみ描画しているようです。

TIPS

使用シーンは見極めよう

今回紹介したオクルージョンクエリによるカリングは、あらゆるシーンで大きなパフォーマンス向上が期待できるものではありません。バウンディングボックスの描画コストも0ではありませんし、判定に遅延が発生してコードは複雑化しますので、使い所はなかなか難しいように思います。オブジェクトが多かったり視点の近くに大きなオブジェクトがあるシーンや、小さなオブジェクトであればそれ自体に遮蔽が発生しやすく、ムダなレンダリングが多くなるので効果的です。また、ポリゴン数が多かったり、複雑なフラグメントシェーダー(マテリアル)のオブジェクトに対して行うと大きな効果が発揮できます。使用シーンを見極めて導入してみましょう。

精度は劣るが、より高速なANY_SAMPLES_PASSED_CONSERVATIVE

深度テストの成功を確認する計測項目として、ANY_SAMPLES_PASSEDの他にANY_SAMPLES_PASSED_CONSERVATIVEがあります。これは、機能としてはANY_SAMPLES_PASSEDと同じですが、精度をトレードオフにしてより高速なアルゴリズムで計測できるかもしれません。犠牲となった精度は、偽陽性を引き起こすことがあります。つまり、すべてのサンプルで深度テストに失敗しているのに、成功したサンプルがあるとカウントされてしまう(クエリ結果に1が返る)可能性があります。

このあたりは実際に使ってみてどちらか選択するとよいと思いますが、結果として、精度も速度もどちらもそんなに大きく変わらないんじゃないかと思います。たとえばWebGLライブラリであるBabylon.jsでは、GL_ANY_SAMPLES_PASSED_CONSERVATIVEがデフォルト値のようなので、これに倣うのもいいでしょう。

深度テストに成功したサンプル数を計測できるSAMPLES_PASSEDはWebGLにはない

WebGL 2.0のANY_SAMPLES_PASSEDおよびANY_SAMPLES_PASSED_CONSERVATIVEは、OpenGLのGL_ANY_SAMPLES_PASSED​およびGL_ANY_SAMPLES_PASSED_CONSERVATIVE​が基ととなっています。OpenGLにはこの2つの他にGL_SAMPLES_PASSED​という計測項目があり、これは、計測期間中の深度テストに成功したサンプル数を取得できる機能です。WebGLにこの機能があった場合はSAMPLES_PASSED​という名前になるでしょうが、残念ながらWebGL 2.0の基となっているOpenGL ES 3.0にGL_ANY_SAMPLES_PASSEDがないため、WebGLにSAMPLES_PASSED​はありません。この機能があれば、カリングするか否かのサンプル数の閾値を自分で決定できますが、1つでもテストに成功したサンプルがあれば終了できるGL_ANY_SAMPLES_PASSEDよりも内部処理の負荷が高いなどの理由があるのかもしれません。

MDNのAPI説明では、ANY_SAMPLES_PASSEDの説明として、「深度テストに成功したサンプル数が取得できる(whether the scoped drawing commands pass the depth test and if so, how many samples pass)」と書かれています。SAMPLES_PASSEDのような説明になっていますが、これは誤りで、ANY_SAMPLES_PASSEDでは深度テストに成功したサンプルがあるか否か(10)しか取得できませんので注意しましょう。

リファレンス

WebGLQueryについて、詳しくは下記のドキュメントを参照ください。

オクルージョンクエリを使用したカリングについて、下記のサイトを参考にしています。