はじめに
NEUTRANS BIZのベース機能に、Grabというものがあります。挙動は名前の通りで、レーザーポインターを当てた、もしくは直接触っているアイテムを掴んで、移動させることが出来ます。
ある時、アイテムを掴める状態を分かりやすくしてほしいというオーダーがあった為、それを実現するために、掴めるアイテムに対してアウトラインを表示する仕組みを作成しました。
この機能はすでに最新版のNEUTRANS BIZに搭載されています。
手法の検討
アウトラインの実現方法としては、メッシュを法線方向に膨らませて反転させるものが一般的です。これはお手軽ですが、いくつかの欠点があります。
- ハードエッジの表現が苦手
- シェーダーに手を入れる、もしくはモデルにマテリアルを追加する必要がある。
上画像をよくみるとアウトラインが途切れてしまっています。丸みを帯びた物体であれば問題にはなりにくいのですが、このような金属質の物体はエッジを利かせているものが多く、その場合単純に頂点を押し出しただけのアウトラインは破綻してしまいます。
以上の欠点を解消する為に、ComputeShaderとImageEffectを利用して、対象オブジェクトのレイヤー変更のみでアウトラインのON/OFFを切替できる仕組みを開発しました。
この方式の利点は以下の通りです
- オブジェクトのメッシュ特性やマテリアルに表示が左右されない
- 遠くのオブジェクトであっても太さが変わらない為視認性が高い
- ハードエッジに強い
逆に欠点は以下の通りです
- レンダリング用にレイヤーを使うため、物理コンポーネントとは別オブジェクトに分ける必要がある
- イメージエフェクトと追加カメラを利用している為、それなりに重い
仕組み
動作のイメージは以下のようになります。
- アウトラインを表示させたいオブジェクトをアウトラインレイヤーに移動する。
- アウトラインレイヤーのみ映すカメラ(シルエットカメラ)でリファレンス画像をレンダリングする。
- ComputeShaderでリファレンス画像からエッジ検出してアウトラインを生成する。
- 通常のレンダリング結果とアウトラインを合成する。
工夫してみた点
Depthの考慮
前述の仕組みによりアウトラインの表示は可能になりました。ただしこの状態では、アウトラインを出すオブジェクトが他のオブジェクトの背後にあってもアウトラインが出てしまいます。
この違和感はVR中の酔いの原因になりかねない為、対策を行います。
SetReplacementShader
を利用してシルエットカメラのレンダリングに利用するシェーダーを変更し、深度情報をカラーで出力してもらうようにしました。*1
// シルエットカメラにDepth出力用シェーダーをセットする silhouetteCamera.SetReplacementShader(settings.DepthShader, "RenderType");
// 通常カメラの深度情報は以下のコードで取得可能 var eyeDepth = Shader.GetGlobalTexture("_CameraDepthTexture");
そしてComputeShader内で、通常カメラのレンダリング結果の深度情報と比較を行い、アウトラインがオブジェクトに隠れているかどうかの判断を行うようにしました。
float sumDepth = 0; float avaiableCount = 0; for (int i = -outlineSize; i <= outlineSize; i++) { for (int j = -outlineSize; j <= outlineSize; j++) { int pos = selfpos + i + (j * threadGroupSize_x); float depth = silhouetteDepth[pos]; float avariable = depth > 0; sumDepth += depth * avariable; avaiableCount += avariable; } } float ztest = ((sumDepth / avaiableCount) - SourceDepth[did]) > 0;
これで上手く遮蔽出来ます。 ※デバッグ用テクスチャの色は強調しています。
負荷削減
ピクセル情報の参照はGPUの中で(比較的)重い処理です。 単純に周囲のピクセルを参照すると、アウトラインの厚みによってピクセル当たりの参照ピクセル数がで増加します。単純計算すると3ピクセルの厚みのアウトラインを作る為にはピクセル参照する必要があります。*2
コンピュートシェーダーには、同じスレッドグループ内のスレッド同士で使える共有メモリが存在します。 これを利用する事で、隣のスレッドが取得したピクセル情報を使いまわせるため、結果としてピクセル当たりの参照回数を抑えることが出来ます。
ただし、シェアードメモリのサイズ及びスレッドグループの大きさには制限があり、一般的なFullHDの画像すべてのピクセルをシェアードメモリに載せることは出来ない為、多少工夫して何とかします。
// 例えばこのようなシェアードメモリを定義する groupshared float silhouettePixelBuffer[ThreadGroupSize_X * ThreadGroupSize_Y* 4];
void DoOutline(uint2 gtid : SV_GroupThreadID, uint2 gid : SV_GroupID) { // 担当アドレスを計算 int2 sharedID = gtid * 2; int2 bufferID = int2(gtid.x * ThreadGroupSize_X - ThreadGroupSize_X * 0.5 + sharedID.x, gid.y * ThreadGroupSize_Y - ThreadGroupSize_Y * 0.5 + sharedID.y); // 担当データを書き込む silhouettePixelBuffer[ThreadGroupSize_X * 2 * sharedID.y + sharedID.x] = Silhouette[bufferID]; silhouettePixelBuffer[ThreadGroupSize_X * 2 * (sharedID.y + 1) + sharedID.x] = Silhouette[bufferID + int2(0, 1)]; silhouettePixelBuffer[ThreadGroupSize_X * 2 * sharedID.y + (sharedID.x + 1)] = Silhouette[bufferID + int2(1, 0)]; silhouettePixelBuffer[ThreadGroupSize_X * 2 * (sharedID.y + 1) + (sharedID.x + 1)] = Silhouette[bufferID + int2(1, 1)]; // グループ内全てのスレッドで上の処理が終わるのを待つ GroupMemoryBarrierWithGroupSync(); // この後はsilhouettePixelBufferを参照する処理を組む
結果はこちらです。
思いっきり負荷をかけて改善3msという程度だったので、やらなくても良かったかもしれません……。
最後に
以上の仕組みは、実は2017年頃に作成したものです。この度テックブログの記事としてちょうどいい題材ではないかと持ち掛けられたため、引っ張り出してきて記事にしました。
当時はほぼ新入社員だったのですが、自由にコードを書かせてもらって今に至ります。
Synamonでは現在仲間を絶賛採用中です。ご興味のある方は是非一度お話ししましょう!