WebGLコンテンツは意識せずに作ると、時に負荷の高いものが出来上がります。特に、スペックの良い環境で開発していると、エンドユーザーが閲覧する環境からの乖離が大きくなりがちです。そこで、重要になるのが最適化です。最適化は無闇な対応でなく、どこに時間がかかっているのか(ボトルネック)をきちんと計測して行うことが大切です。
WebGLでは大きく分けてCPUとGPU、2種類のボトルネックが考えられます。CPUのボトルネックプロファイリングについては今回置いておきましょう。いまさら説明するまでもなく、記事「WebGLのドローコール最適化手法」の対策のようにみなさんがいままでやってきたことと思います。では、GPUのボトルネックについて我々はどのようにして知れば良いでしょうか?
WebGL 2.0 で追加されたWebGLQuery
と、EXT_disjoint_timer_query_webgl2
拡張を使うとシェーダーのGPU実行時間を計測できます。本記事ではWebGLコンテンツの最適化に欠かせないGPUプロファイル、その最初のステップについて紹介します。
CPUとGPUは非同期に動作する
WebGLの機能のうち、GPUが実行する部分はどのように計測すれば良いでしょうか? たとえば、描画命令の実行時間を知りたいときは? JavaScriptではWebGLの描画命令APIを呼び出して画面を描画しています。この描画命令の実行前後で時間を測れば良いでしょうか?
実はこれは違います。JavaScriptで実行するWebGLの描画命令はCPUの処理であり、GPUへの命令コマンドを作るだけで、その後描画命令のコマンドを非同期にGPUが実行します。なのでJavaScriptで描画命令を実行し、制御が返ってきたとしても実際にGPUで画面が描画されているわけではありません。そのため、処理負荷の高いシェーダーを使用した描画命令であっても、描画API呼び出しの時間はまったくかからないことがあります。逆に、処理の軽いシェーダーであっても描画APIを何度も呼び出すことで(これが俗に言うドローコールの過多です)GPUの処理の時間はたいしたことがないのに描画命令の呼び出し(CPU処理)に時間がかかりFPSが落ちることもあります。
上記の通り、シェーダーの実行(GPUの処理)時間は通常のJavaScript APIでは計測することはできません。そこで、今回紹介するEXT_disjoint_timer_query_webgl2
拡張の出番です。
WebGL 2.0 で追加されたWebGLQuery
機能
WebGL 2.0 で追加されたWebGLQuery
は、GPUのさまざまな状態を問い合わせ、取得する機能です。さまざまと言っても、WebGL 2.0 の仕様で使用できるのはたったの2種類です。
- 深度テストに成功したピクセル数を取得できる
ANY_SAMPLES_PASSED
およびANY_SAMPLES_PASSED_CONSERVATIVE
- Transform Feedback機能で書き戻されたプリミティブの数を取得できる
TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN
しかし、EXT_disjoint_timer_query_webgl2
拡張を使用すると、上記の2種類に加えてWebGLのGPUコマンドの実行時間をクエリで問い合わせできるようになります。
正確には、「計測開始地点より前に呼び出されたGPUコマンドが終了したタイミング」から、「計測終了地点より前に呼び出されたGPUコマンドが終了したタイミング」の間の時間です
残念ながら2020年6月現在、EXT_disjoint_timer_query_webgl2
拡張を使用できるブラウザはChromeのみのため、以降はChrome環境を前提とします。
サンプルの紹介
EXT_disjoint_timer_query_webgl2
拡張を使ったサンプルを紹介します。
- サンプルを別ウインドウで再生する(WebGL 2.0のEXT_disjoint_timer_query_webgl2拡張に対応したブラウザでご覧ください。2020年6月現在、Chromeのみ対応しています)
- サンプルのソースコードを確認する
このサンプルでは、かんたんなレイマーチングの画面を表示しています。レイマーチングはフラグメントシェーダーの各ピクセルでレイを繰り返し評価しますが、繰り返し回数が多いほどシーンの遠くまで描画できます。しかし、繰り返し回数が増えると処理時間が増大します。
このループ回数をGUIで操作できるようにしたので、画面右上のスライダーを操作してください。ループ回数が少ないと近くの球のみが表示され、多いほど遠くまで表示されるかわりにFPSの低下が確認できます。
レイマーチングなので描画命令は1回です。この描画命令(drawElements()
)実行の前後でCPU(performance.now()
)およびGPU(EXT_disjoint_timer_query_webgl2
)の実行時間を計測し、画面左上に表示しています。FPSはrequestAnimationFrame()
間の差分から計算しています。
スライダーでループ回数を増やすとFPSが落ちますが、CPUの実行時間はまったく変わっていないことに気づくでしょう。シェーダーの負荷がいくら高くてもJavaScriptでコールした描画命令(drawElements()
)はすぐに実行が完了してJavaScriptに制御が戻っていることがわかります。また、FPSが落ちていることから、GPUの実行時間に同期して次回のrequestAnimationFrame()
の実行が遅れていることが見てとれます。これが「CPU負荷は低いのにGPU負荷が高いせいでFPSが低下している状態」です。
APIの使い方
いつものように、最初に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_RESULT
をgl2.getQueryParameter()
メソッドの第二引数にして呼び出します。
// クエリ結果を取得
const result = gl2.getQueryParameter(query, gl2.QUERY_RESULT);
ここで注意があります。クエリ結果を取得するには、クエリオブジェクトがクエリ結果取得可能状態になっている必要があります。クエリ結果取得可能状態はgl2.QUERY_RESULT_AVAILABLE
をgl2.getQueryParameter()
メソッドの第二引数にして呼び出して取得します。この値がtrue
であればクエリ結果を取得できます。
// クエリ結果が取得可能か確認
const resultAvailable = gl2.getQueryParameter(query, gl2.QUERY_RESULT_AVAILABLE);
if (resultAvailable) {
// クエリ結果を取得
const result = gl2.getQueryParameter(query, gl2.QUERY_RESULT);
}
さらに大きな注意があります。WebGLの仕様上、クエリ結果が取得できるのは、次フレーム以降になります。これが面倒なところで、計測したクエリオブジェクトが次のフレームで結果取得可能になる保証がない(実際にやってみると3、4フレームほど遅れます)ので、次のフレームの計測に使用すると結果を上書きしてしまいます。そのため、複数のクエリオブジェクトを使用する必要があります。
サンプルでは使用中のクエリオブジェクトのリスト(usingList
)と使用可能なクエリオブジェクトのリスト(availableList
)を作りました。計測時には使用可能なリストからクエリオブジェクトを取得し、なければ新たに作成します。計測が終わったら使用中のリストに追加します。結果を取得するときは使用中のリストのクエリオブジェクトの結果取得可能かをチェックし、結果を取得できたら使用可能なリストに戻します。
というわけで下記がWebGLQuery
を使用する完全なコードです。
▼ 計測時
// 使用可能なクエリをリストから取得(なければ新たに作成)
const availableQuery = availableList.length ? querySet.availableList.shift() : gl2.createQuery();
// 計測を開始
gl2.beginQuery(target, availableQuery);
// 計測するWebGLコマンド
...
// 計測を終了
gl2.endQuery(target);
// 使用中のクエリリストに移動
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());
}
}
EXT_disjoint_timer_query_webgl2
拡張の使い方
今回の記事の主役であるEXT_disjoint_timer_query_webgl2
拡張の使い方を説明します。といってもこれはWebGLQuery
で使用できるtarget
のひとつなので、WebGLQuery
が分かれば難しいことはありません。
まずはWebGLの拡張機能のお約束、有効化です。getExtension()
メソッドでEXT_disjoint_timer_query_webgl2
拡張を取得します。実行環境が拡張機能に対応していればオブジェクトが返り、対応していなければnull
が返ります。
// EXT_disjoint_timer_query_webgl2拡張を取得(有効化)
const ext = gl2.getExtension("EXT_disjoint_timer_query_webgl2");
EXT_disjoint_timer_query_webgl2
拡張を有効化すると、WebGLQuery
のbeginQuery()
メソッドの第一引数のtarget
としてTIME_ELAPSED_EXT
が使用できるようになります。今回計測するのは描画コマンドなので、drawElements()
メソッドの前後をbeginQuery
とendQuery()
で囲みます。
// GPU実行時間計測開始
gl2.beginQuery(ext.TIME_ELAPSED_EXT, query);
// 描画コマンド
gl2.drawElements(...);
// GPU実行時間計測終了
gl2.endQuery(ext.TIME_ELAPSED_EXT);
結果取得時はWebGLQuery
で説明したとおり……と言いたいところですが、EXT_disjoint_timer_query_webgl2
拡張の場合のみ注意があります。
それは、GPUでdisjoint operation
が発生していないかチェックする必要があることです。disjoint operation
とは、フレーム内でGPUのクロック周波数が変化する場合などに起こるようです。これが発生するとGPUが不整合な状態となっており、クエリの結果が時間計測の値として有効ではなくなります。
disjoint operation
の発生は、拡張機能で追加されたGPU_DISJOINT_EXT
値をgetParameter()
メソッドの引数に指定することで検知可能です。前回GPU_DISJOINT_EXT
をチェックした時点からdisjoint operation
が発生した場合、true
が返却されます。毎フレームdisjoint operation
の発生有無をチェックし、発生していない場合のみクエリ結果を取得します。disjoint operation
が発生していた場合は記録しているクエリが無効な値になっているということなので、破棄します。今回のサンプルではdeleteQuery()
メソッドを使ってクエリごと破棄しています。
// GPUの不整合をチェック
const disjoint = gl2.getParameter(ext.GPU_DISJOINT_EXT);
if (disjoint) {
// 使用中のクエリを全て削除
usingList.forEach(query => gl2.deleteQuery(query));
} else {
// クエリ結果を取得
...
}
なかなか難しい概念ですが、通常発生することはない認識なので、とりあえずEXT_disjoint_timer_query_webgl2
拡張の使用時には結果の取得前にGPU_DISJOINT_EXT
をチェックするとだけ覚えておきましょう。
まとめると、EXT_disjoint_timer_query_webgl2
拡張のクエリ結果取得は下記のステップで行います。
gl2.getParameter(ext.GPU_DISJOINT_EXT)
でdisjoint operation
の発生有無をチェックgl2.getQueryParameter(query, gl2.QUERY_RESULT_AVAILABLE)
でクエリ結果が取得可能となっているかチェックgl2.getQueryParameter(query, gl2.QUERY_RESULT)
でクエリ結果を取得
以上でWebGLQuery
およびEXT_disjoint_timer_query_webgl2
拡張の使い方を説明しました。みなさんも気になっているシェーダー処理の実行時間を計測し、さらなる最適化を目指しましょう。
TIPS
経過時間でなく現在時間を取得できるTIMESTAMP_EXT
機能は現状使用できない
今回紹介したTIME_ELAPSED_EXT
はbeginQuery()
とendQuery()
の間の経過時間を取得できるクエリですが、EXT_disjoint_timer_query_webgl2
拡張の仕様にはもうひとつ、現在時間(タイムスタンプ)を取得できるTIMESTAMP_EXT
値が用意されています。これを使用すると、呼び出し時点のGPU時間(正確には呼び出し直前のGPUコマンドが終了した時間)のタイムスタンプが取得できます。
GPUコマンドの前後でそれぞれタイムスタンプを計測し、差分をとることでそのコマンドの経過時間を取得できます。TIME_ELAPSED_EXT
と違い、入れ子にして計測できるメリットがあるのでなかなか便利な機能です。なのですが、なぜ今回のサンプルで紹介しなかったかというと、現状、唯一EXT_disjoint_timer_query_webgl2
拡張が使用可能なブラウザであるChromeでこのTIMESTAMP_EXT
が使用できないためです。
Windows,macOSともにTIMESTAMP_EXT
のWebGLへの移植ができないとのことです。今後サポートする予定もなさそうなので、諦めて推奨されているTIME_ELAPSED_EXT
を使用しましょう。
WebGL 1.0 でも使えるEXT_disjoint_timer_query
拡張
WebGL 2.0 でのみ使用できるEXT_disjoint_timer_query_webgl2
拡張ですが、実はWebGL 1.0 にも同等の拡張機能EXT_disjoint_timer_query
があります。使用方法は基本的に同じなのですが、WebGLQuery
が WebGL 2.0 で追加されたAPIのため、 WebGL 1.0 にはありません。そこでWebGLQuery
相当のWebGLTimerQueryEXT
APIがEXT_disjoint_timer_query
拡張に用意されています。
下記に本記事で使用している WebGL 2.0 のWebGLQuery
APIとWebGL 1.0 で使用できるEXT_disjoint_timer_query
拡張のWebGLTimerQueryEXT
APIの対応をまとめました。
WebGL 2.0(WebGLQuery ) |
WebGL 1.0(EXT_disjoint_timer_query ) |
内容 |
---|---|---|
createQuery() | createQueryEXT() | クエリの作成 |
deleteQuery() | deleteQueryEXT() | クエリの削除 |
beginQuery() | beginQueryEXT() | クエリの開始 |
endQuery() | endQueryEXT() | クエリの終了 |
getQueryParameter() | getQueryObjectEXT() | クエリ情報の取得 |
QUERY_RESULT_AVAILABLE | QUERY_RESULT_AVAILABLE_EXT | クエリ結果が取得可能かどうか |
QUERY_RESULT | QUERY_RESULT_EXT | クエリ結果 |
名前が違うだけなので、 ぜひ WebGL 1.0 でも試してみてください。
EXT_disjoint_timer_query_webgl2
拡張と脆弱性
EXT_disjoint_timer_query_webgl2
の高精度な時間計測を悪用することでメモリ配置を特定し、Rowhammer攻撃を仕掛けるGLitchという攻撃方法があります。GLitch攻撃は2018年に大きな話題になったSpectreやMeltdownといった脆弱性攻撃のきっかけにもなりえるということで、各ブラウザは拡張機能を非公開にし、フラグ等の有効化によってのみ使用できるようにしました。
Chrome
Chromeでは現在はフラグの設定なしにEXT_disjoint_timer_query_webgl2
拡張を使用できるようになりましたが、取得できるタイマーの精度を落とし、仕様のナノ(10の-9乗)秒ではなくマイクロ(10の-6乗)秒オーダーで提供することで脆弱性を回避しています。取得したQUERY_RESULT
を出力すると下3桁は必ず000
となっていることが確認できます。マイクロ秒でもプロファイルには充分な精度ですね。
Firefox
Firefoxでは現在もフラグの設定が必要で、about:configページからwebgl.enable-privileged-extensions
フラグをtrue
にすることで前述のEXT_disjoint_timer_query
拡張が使用できるようになります。ここで注意が必要なのが、Firefoxでは WebGL 2.0 で使用できるのはEXT_disjoint_timer_query_webgl2
拡張ではないということです。WebGL 1.0 でも WebGL 2.0 でもEXT_disjoint_timer_query
拡張のみが使用できます。上記脆弱性の観点から、このフラグは使用するときのみtrue
にすることを推奨します。
リファレンス
EXT_disjoint_timer_query_webgl2
拡張およびWebGLQuery
について、詳しくは下記のドキュメントを参照ください。