Unityで対象オブジェクトに(ほぼ)手を入れずに使えるアウトラインエフェクトを作った

f:id:Sokuhatiku:20200720225722p:plain
空中ペンで書いた文字に表示されるアウトライン

はじめに

NEUTRANS BIZのベース機能に、Grabというものがあります。挙動は名前の通りで、レーザーポインターを当てた、もしくは直接触っているアイテムを掴んで、移動させることが出来ます。

ある時、アイテムを掴める状態を分かりやすくしてほしいというオーダーがあった為、それを実現するために、掴めるアイテムに対してアウトラインを表示する仕組みを作成しました。

この機能はすでに最新版のNEUTRANS BIZに搭載されています。

手法の検討

アウトラインの実現方法としては、メッシュを法線方向に膨らませて反転させるものが一般的です。これはお手軽ですが、いくつかの欠点があります。

  • ハードエッジの表現が苦手
  • シェーダーに手を入れる、もしくはモデルにマテリアルを追加する必要がある。

f:id:Sokuhatiku:20200720210109p:plain
メッシュ押し出し反転によるアウトライン

上画像をよくみるとアウトラインが途切れてしまっています。丸みを帯びた物体であれば問題にはなりにくいのですが、このような金属質の物体はエッジを利かせているものが多く、その場合単純に頂点を押し出しただけのアウトラインは破綻してしまいます。

以上の欠点を解消する為に、ComputeShaderとImageEffectを利用して、対象オブジェクトのレイヤー変更のみでアウトラインのON/OFFを切替できる仕組みを開発しました。

f:id:Sokuhatiku:20200720211303g:plain
レーザーポインターを当てるとレイヤーが切り替わる

この方式の利点は以下の通りです

  • オブジェクトのメッシュ特性やマテリアルに表示が左右されない
  • 遠くのオブジェクトであっても太さが変わらない為視認性が高い
  • ハードエッジに強い

逆に欠点は以下の通りです

  • レンダリング用にレイヤーを使うため、物理コンポーネントとは別オブジェクトに分ける必要がある
  • イメージエフェクトと追加カメラを利用している為、それなりに重い

仕組み

動作のイメージは以下のようになります。

  1. アウトラインを表示させたいオブジェクトをアウトラインレイヤーに移動する。
  2. アウトラインレイヤーのみ映すカメラ(シルエットカメラ)でリファレンス画像をレンダリングする。
  3. ComputeShaderでリファレンス画像からエッジ検出してアウトラインを生成する。
  4. 通常のレンダリング結果とアウトラインを合成する。

f:id:Sokuhatiku:20200720223840p:plain

工夫してみた点

Depthの考慮

前述の仕組みによりアウトラインの表示は可能になりました。ただしこの状態では、アウトラインを出すオブジェクトが他のオブジェクトの背後にあってもアウトラインが出てしまいます。

f:id:Sokuhatiku:20200720191931p:plain
キューブを貫通してアウトラインが見えてしまう

この違和感は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;

f:id:Sokuhatiku:20200720191822p:plain

これで上手く遮蔽出来ます。 ※デバッグ用テクスチャの色は強調しています。

負荷削減

ピクセル情報の参照はGPUの中で(比較的)重い処理です。 単純に周囲のピクセルを参照すると、アウトラインの厚みによってピクセル当たりの参照ピクセル数が(2n+1)^ 2で増加します。単純計算すると3ピクセルの厚みのアウトラインを作る為には\{(2 * 3) + 1\}^ 2=49ピクセル参照する必要があります。*2

f:id:Sokuhatiku:20200721125835p:plain
3pxのアウトラインを出すためには各ピクセルが7x7の範囲を認識する必要がある

コンピュートシェーダーには、同じスレッドグループ内のスレッド同士で使える共有メモリが存在します。 これを利用する事で、隣のスレッドが取得したピクセル情報を使いまわせるため、結果としてピクセル当たりの参照回数を抑えることが出来ます。

ただし、シェアードメモリのサイズ及びスレッドグループの大きさには制限があり、一般的な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を参照する処理を組む

結果はこちらです。 f:id:Sokuhatiku:20200720203503p:plain

思いっきり負荷をかけて改善3msという程度だったので、やらなくても良かったかもしれません……。

最後に

f:id:Sokuhatiku:20200721140442g:plain
VRかつ複雑な形状でも綺麗にアウトラインが出るところがお気に入り

以上の仕組みは、実は2017年頃に作成したものです。この度テックブログの記事としてちょうどいい題材ではないかと持ち掛けられたため、引っ張り出してきて記事にしました。

当時はほぼ新入社員だったのですが、自由にコードを書かせてもらって今に至ります。

Synamonでは現在仲間を絶賛採用中です。ご興味のある方は是非一度お話ししましょう!

herp.careers

*1:このシェーダー差し替え処理のトレードオフとして、GeometryShader等シェーダー内で頂点を弄る処理があった場合、ケアしなければ破綻するようになってしまいますが……。

*2:実際は円形に描画する為、角部分の参照は減らせます。