オクルージョンクエリを使った衝突判定の戦略

前のページで考えた衝突条件①’「視点から見てAの表面よりBの裏面が奥にあるピクセルが1ピクセル以上ある」および②’「視点から見てBの表面よりAの裏面が奥にあるピクセルが1ピクセル以上ある」を深度テストとオクルージョンクエリで実現してみましょう。まず、①’について、隠面カリングを使えばAの表面のみ、Bの裏面のみを描画することは可能ですね。そして言葉の順番通り受け取れば、深度バッファにAの表面の深度値を記録しておき、Bの裏面を描画したときに合格条件「以上」の深度テストで1ピクセル以上合格していればいいわけです。オクルージョンクエリで取得したいのはBの合格状況なので、計測区間は「Bの描画中」です。

このとき、Aが描画されていないピクセルについてはBの描画時にテストに失敗してもらわないと衝突を誤検知する(見え方の9つの分類のケース3にあたります)ので、深度バッファには1.0が格納されているべきです。1.0より大きい深度値はないので、「以上」の条件の深度テストは常に失敗します。Aの表面を描画した段階で、Aの描画ピクセルにはAの表面の深度値、それ以外のピクセルは1.0が記録されているのが深度バッファの期待値です。描画されない領域のピクセルについては寄与できないので、これを実現するには、深度バッファの初期値1.0に設定します。そして、Aの表面描画時は「以下」の条件で深度テストをして深度バッファを更新すれば期待の状態を作り出せます。

まとめると、①’は下記の手順になります。

  1. 深度バッファを1.0でクリアする
  2. Aの表面を深度テスト合格条件「以下」で描画し、深度バッファを更新する
  3. オクルージョンクエリの計測を開始する
  4. Bの裏面を深度テスト合格条件「以上」で描画する
  5. オクルージョンクエリの計測を終了する

②’はAとBの役割が反転するだけですので、下記の手順になります。

  1. 深度バッファを1.0でクリアする
  2. Bの表面を深度テスト合格条件「以下」で描画し、深度バッファを更新する
  3. オクルージョンクエリの計測を開始する
  4. Aの裏面を深度テスト合格条件「以上」で描画する
  5. オクルージョンクエリの計測を終了する

本当にこれで衝突を正しく検知できるのでしょうか? いくつかのパターンを図に描いて試してみましょう。

ケース1 AとBの一部が衝突している場合に正しく判定できるか

まずは、AとBが衝突しているケース1です。Aの表→Bの裏の順番で描画したとき、図中ステップ3で深度テストに成功し、描画されるピクセル(青線)が1ピクセル以上あるため、クエリ結果は1になります。

ケース1:衝突している場合にA→Bの順番で描画したときのクエリ結果

役割を交換し、今度はBの表→Aの裏の順番で描画します。このときも、ステップ3で深度テストに成功するピクセルがあるため、クエリ結果は1です。A→B、B→Aのどちらのテストにおいてもクエリ結果が1だったので、衝突条件①’、②’をみたし、衝突していることが判定できます。

ケース1:衝突している場合にB→Aの順番で描画したときのクエリ結果

ケース2 AがBに内包している場合に正しく判定できるか

今度は、AがBに完全に内包している状態で考えてみます。オブジェクトの形状も丸っこくしてみます。A→B、B→A、どちらの場合でもクエリ結果が1となることがわかります。他方のオブジェクトを内包している場合でもこの条件で衝突を判定できています。

ケース2:衝突(完全に内包)している場合にA→Bの順番で描画したときのクエリ結果 ケース2:衝突(完全に内包)している場合にB→Aの順番で描画したときのクエリ結果

ケース3 AとBが衝突していない場合に正しく判定できるか

では、衝突していないケースではどうでしょうか? A→Bの描画では、Bの裏面がAの表面より後ろにあるので、クエリ結果は1になります。しかし、Aの裏面は完全にBの表面より前にあるため、B→Aの描画のクエリ結果は0です。衝突条件①’はみたしているものの、②’をみたしていないので非衝突と判定できます。衝突していない場合も正しく判定できていることがわかります。

ケース3:衝突していない場合にA→Bの順番で描画したときのクエリ結果 ケース3:衝突していない場合にB→Aの順番で描画したときのクエリ結果

JavaScriptによる実装

判定に必要な情報は揃ったので、実際のコードをみてみましょう。WebGLQueryおよびANY_SAMPLES_PASSEDクエリの使い方はANY_SAMPLES_PASSEDの記事を参照ください。前のページ冒頭のデモはコードが複雑になってしまったので、簡易的なデモを用意しました。左上にはA(赤)→B(緑)の順番で描画した場合のクエリ結果とB→Aの順番のクエリ結果と、その2つの結果を&演算で衝突判定とした結果を表示しています。

オクルージョンクエリを使用した衝突判定(簡易版):デモ画像

※このサンプルでは特定の条件で偽陽性(実際には衝突していないのに衝突していると判定してしまう)となることがあります。詳しい条件や理由については後述します。

いつもの通り、以下gl2Canvasから取得したWebGL2RenderingContextオブジェクトを指します。

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

事前に描画の設定をしておきます。この設定は途中で変更しないので、1度だけ行います。ポイントは隠面カリング機能と深度テスト機能を有効化することです。これらの機能はデフォルトではオフになっています。また、深度バッファのクリア値は1.0にします。

// 描画の設定
// カラーバッファのクリア色を設定
gl2.clearColor(0.5, 0.5, 0.5, 1.0);
// 隠面カリングを有効化
gl2.enable(gl2.CULL_FACE);
// CCWを表面に設定
gl2.frontFace(gl2.CCW);
// 深度テストを有効化
gl2.enable(gl2.DEPTH_TEST);
// 深度バッファのクリア値を設定
gl2.clearDepth(1.0);

ここからは、requestAnimationFrame()で毎フレーム実行する処理を大きく以下の3つに分けて説明します。

  1. 衝突判定のクエリを記録する
  2. クエリ結果を読み取って衝突判定を行う
  3. 衝突判定の結果を使用して画面更新する

衝突判定のクエリを記録する

処理上の順番は結果取得のコードと前後しますが、先に深度テストとクエリによる記録処理について説明します。まずはカラーバッファへの書き込みをオフにします。ここで必要なのは深度バッファへの書き込みのみのため、ライティングなどの計算を省いた処理の軽いシェーダーを使用します

// 最初に深度のみで判定するため、カラーバッファへの書き込みを無効化
gl2.colorMask(false, false, false, false);

// 深度のみ描画するシェーダーに変更
gl2.useProgram(depthWriteProgramSet.program);

最初にA→Bの順番で描画を実行します。深度バッファをクリアし、ステップ1でモデルAの表面を描画します。深度バッファへの書き込みを有効化し、「以下」の場合に更新されるようにします。描画するAのオブジェクトのうち、視点から一番近いピクセルの深度値が深度バッファに記録され、描画されていない部分の深度値は1.0のままとなります。

// 深度バッファへの書き込みを有効化(深度バッファをクリアするため、このタイミングで)
gl2.depthMask(true);
// ステップ1 深度バッファをクリア
gl2.clear(gl2.DEPTH_BUFFER_BIT);

// 赤→緑の順番で描画を実行する(緑の裏面が完全に赤の表面より前にあるかどうかを計測)

// ステップ2
  
// 深度テストの合格条件を「以下」に設定
gl2.depthFunc(gl2.LEQUAL);
// 深度バッファへの書き込みを有効化(上記ですでに呼んでいるので不要)
// gl2.depthMask(true);
// 表面のみ描画
gl2.cullFace(gl2.BACK);
// 最初のモデル(赤)を描画
gl2.drawElements(/* ・・・略(赤のモデルです)・・・ */);

ステップ2ではモデルBの裏面を描画し、モデルBの描画されるすべてのピクセルでモデルAの表面より前にあるかどうかをクエリ計測します。テスト結果だけが知りたいので、深度バッファは更新する必要はなく、書き込みを無効化します。深度テストの合格条件は「以上」に設定することで、1ピクセルでもテストに合格すれば、モデルA(深度バッファの各値)よりうしろにあるピクセルが少なくとも1ピクセルはあることがわかります。この場合、衝突の可能性があります(衝突条件①’をみたします)。

// ステップ3
  
// 深度テストの合格条件を「以上」に設定
gl2.depthFunc(gl2.GEQUAL);
// 深度バッファへの書き込みを無効化
gl2.depthMask(false);
// 裏面のみ描画
gl2.cullFace(gl2.FRONT);
// クエリ計測を開始
gl2.beginQuery(gl2.ANY_SAMPLES_PASSED_CONSERVATIVE, getAvailableQuery(queryAB));
// 次のモデル(緑)を描画
gl2.drawElements(/* ・・・略(緑のモデルです)・・・ */);
// クエリ計測を終了
gl2.endQuery(gl2.ANY_SAMPLES_PASSED_CONSERVATIVE);

A→Bの順番で描画することで、モデルBがモデルAより完全に前にあるかどうか(衝突条件①’)がクエリに記録されました。次はB→Aの順番で、全く同じ判定をします。その前に、深度バッファをクリアすること忘れないでください。A→Bの順番の描画でテストの基準として使用した、モデルAの表面の深度値が格納されたままだからです。処理内容はA→Bの順番を入れ替えただけです。記録するWebGLQueryはA→Bのときと別のものを使用します。1フレームで2つのWebGLQueryを使用します。

// 以下赤と緑の役割を交換して再度同じ処理を実行

// 深度バッファへの書き込みを有効化(深度バッファをクリアするため、このタイミングで)
gl2.depthMask(true);
// ステップ1 深度バッファをクリア
gl2.clear(gl2.DEPTH_BUFFER_BIT);

// 緑→赤の順番で描画を実行する(赤の裏面が完全に緑の表面より前にあるかどうかを計測)

// ステップ2
  
// 深度テストの合格条件を「以下」に設定
gl2.depthFunc(gl2.LEQUAL);
// 深度バッファへの書き込みを有効化(上記ですでに呼んでいるので不要)
// gl2.depthMask(true);
// 表面のみ描画
gl2.cullFace(gl2.BACK);
// 最初のモデル(緑)を描画
gl2.drawElements(/* ・・・略(緑のモデルです)・・・ */);
  
// ステップ3
  
// 深度テストの合格条件を「以上」に設定
gl2.depthFunc(gl2.GEQUAL);
// 深度バッファへの書き込みを無効化
gl2.depthMask(false);
// 裏面のみ描画
gl2.cullFace(gl2.FRONT);
// クエリ計測を開始
gl2.beginQuery(gl2.ANY_SAMPLES_PASSED_CONSERVATIVE, getAvailableQuery(queryBA));
// 次のモデル(赤)を描画
gl2.drawElements(/* ・・・略(赤のモデルです)・・・ */);
// クエリ計測を終了
gl2.endQuery(gl2.ANY_SAMPLES_PASSED_CONSERVATIVE);

クエリ結果を読み取って衝突判定を行う

記録したクエリを確認し、衝突しているかを判定します。このWebGLQueryは数フレーム前の情報なので、開始数フレームは衝突判定を行えません。そのため、記録済みで結果取得可能なWebGLQueryがない場合は強制的に非衝突として扱います。gl2.getQueryParameter(query, gl2.QUERY_RESULT_AVAILABLE)で結果取得可能かをチェックして、gl2.getQueryParameter(query, gl2.QUERY_RESULT)で結果(10)を取得します。A→Bの結果とB→Aの結果が両方利用可能で、両方とも1(深度テストに合格したピクセルが1ピクセル以上ある)だったときのみ衝突判定をtrueとします。

// 衝突しているかどうか
let isCollided = false;

// 過去に取得したクエリから衝突を判定

// A→Bの結果を取得
const resultAvailableQueryAB = getResultAvailableQuery(queryAB);
let anySamplesPassedConservativeAB;
if (resultAvailableQueryAB) {
  anySamplesPassedConservativeAB = gl2.getQueryParameter(resultAvailableQueryAB, gl2.QUERY_RESULT);
  queryAB.dom.innerText = anySamplesPassedConservativeAB;
}
// B→Aの結果を取得
const resultAvailableQueryBA = getResultAvailableQuery(queryBA);
let anySamplesPassedConservativeBA;
if (resultAvailableQueryBA) {
  anySamplesPassedConservativeBA = gl2.getQueryParameter(resultAvailableQueryBA, gl2.QUERY_RESULT);
  queryBA.dom.innerText = anySamplesPassedConservativeBA;
}
// 衝突しているかどうかを判定
if (resultAvailableQueryAB && resultAvailableQueryBA) {
  isCollided = anySamplesPassedConservativeAB && anySamplesPassedConservativeBA;
  collisionDom.innerText = (isCollided === 1).toString();
}

衝突判定の結果を使用して画面更新する

最後に、得られた衝突判定を使って画面に表示するためのレンダリングを行います。通常の深度テストを実施するため、深度バッファへの書き込みを有効化し、テストの合格条件を「以下」に戻します。表面のみ描画できればいいので、裏面をカリングします。通常のレンダリングはもちろん絵が見えないとならないので、カラーバッファへの書き込みを有効化します。バッファをクリアして本描画を行います。このときのシェーダーは通常のライティング用シェーダーです。

// 以下本描画に向けて設定
// 深度テストの合格条件を「以下」に設定
gl2.depthFunc(gl2.LEQUAL);
// 深度バッファへの書き込みを有効化
gl2.depthMask(true);
// 表面のみ描画
gl2.cullFace(gl2.BACK);
// カラーバッファへの書き込みを有効化
gl2.colorMask(true, true, true, true);
// カラーバッファと深度バッファをクリア
gl2.clear(gl2.COLOR_BUFFER_BIT | gl2.DEPTH_BUFFER_BIT);

// モデルの本描画
gl2.useProgram(renderProgramSet.program);
for (let i = 0; i < objList.length; i++) {
  // 衝突していればオブジェクトを光らせる
  gl2.uniform1f(renderProgramSet.uniformLocation.collidedColorFactor, isCollided ? 2.0 : 1.0);
  gl2.drawElements(/* ・・・略・・・ */);
}

以上でオクルージョンクエリを使用したGPUベースの衝突判定が完成です。なんと、衝突判定なのに数式や計算がまったく出てきませんでした。ややこしい計算をせずに複雑な形状でも正しく衝突判定できる今回の手法をぜひ試してみてください。

次のページでは、この手法の問題点と、判定の精度をさらに向上する方法について補足します。

WebGL 2.0 - ANY_SAMPLES_PASSED応用編 - オクルージョンクエリで衝突判定を行う(その3)