凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

HoloLens で向こう側が見える窓を動的に追加してみる

はじめに

前回のブログで少しだけ穴あき表現について触れました。

tips.hecomi.com

本エントリでは動的に穴を開ける方法について解説したいと思います。

デモ

環境

ダウンロード

github.com

おさらい

ドキュメント:Case study - Looking through holes in your reality

HoloLens では黒色が透明に見えるため、穴の空いた黒いプレーンを通して向こう側を見るような表現を作れば、壁に穴をあけることも出来ます。しかしこの方法の欠点は、キャプチャをする際は黒は黒として出力されてしまう点です(※ Holographic Remoting Player でのキャプチャは透明になります)。これは以下のような数式で説明できます(厳密に正しい数式ではないので注意)。まず実際の目に見えている画は以下のように表現できます

(現実 * バイザの黒半透明) + ホログラム

ホログラムが黒(= 0)の時は現実がちょっと暗くなっただけとなります。しかしながら、スクリーンショットやビデオを撮った際は以下のようになります。

// a = ホログラムのアルファ
(現実 * (1 - a)) + ホログラムの色 * a

このため、黒が 0 より大きいアルファ値を持っている場合は、透明でなく黒として映ってしまいます。これを回避するには、黒ではなく ColorMask 0 をしてデプスバッファにだけ書き込むシェーダを使った遮蔽用のマテリアルを適用します。シェーダのコードは HoloToolkit に含まれています。

https://github.com/Microsoft/HoloToolkit-Unity/blob/master/Assets/HoloToolkit/Utilities/Shaders/WindowOcclusion.shadergithub.com

方針

動的に穴を開ける方法は色々あると思いますが、メッシュに直接穴を開けるのは大変ですしコスト高です。そこで簡単にできる方法としてステンシルを使った方法を紹介します。

docs.unity3d.com

ステンシルはマスクとして使うことが出来、特定の領域をマーキングしてそこだけ色を付けたりつけなかったりといった事ができます。本ブログでも何度か取り扱いました:

このステンシルを使って HoloLens で壁に穴を開ける方法としては、壁をステンシルで抜く方法と、ステンシルで別の世界を切り出す方法の 2 つが考えられます*1。では 2 つの方式とメリット・デメリットを見ていきましょう。

方法 1

まず、最初の方法は壁をステンシルで抜く方法です。次の 2 つの手順になります。

  1. 窓となるオブジェクトをステンシルでマークして描画
  2. 次に壁オブジェクトを窓オブジェクトのステンシルでマークされた場所以外に描画

窓オブジェクト

適当な窓オブジェクトを作成します。窓枠と窓には別々のシェーダを適用し、窓枠は通常のスタンダードシェーダ、窓には次に説明するシェーダを適用します。

f:id:hecomi:20170218184450p:plain

窓シェーダ

まず、窓となるシェーダから見ていきます。

Shader "HoloLens/Window"
{

Properties
{
   _Mask ("Mask", Int) = 1
}

SubShader
{

Tags 
{ 
    "RenderType" = "Opaque" 
    "Queue" = "Geometry-2"
}

CGINCLUDE

#include "UnityCG.cginc"

struct v2f
{
    float4 vertex : SV_POSITION;
    UNITY_VERTEX_OUTPUT_STEREO
};

v2f vert(appdata_base v)
{
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    o.vertex = UnityObjectToClipPos(v.vertex);
    return o;
}

fixed4 frag (v2f i) : SV_Target
{
    return 0;
}

ENDCG

Pass
{
    ColorMask 0
    ZWrite Off
    Stencil 
    {
        Ref [_Mask]
        Comp Always
        Pass Replace
    }

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    ENDCG
}

}

}

Geometry-2 キューで壁オブジェクトに先駆けて描画をしています。その上で、Stencil ブロックで Comp Always および Pass Replace を使ってプロパティで与えたマスク値をステンシルバッファへ書き込んでいます。この際、ColorMask 0 かつ ZWrite Off にすることで、ステンシルバッファのみに書き込むようにしています(RGB およびデプスバッファには書き込まれない)。これで窓領域にマスクがセットできました。

ちなみに、Unity 5.5 より Single Pass Instanced が有効になっているため、その対応用に UNITY_VERTEX_OUTPUT_STEREO および UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO() のコードを追加しています(しない場合は Single Pass 設定時に片目にしか画が表示されなくなります)。Single Pass の設定は Player Settings から可能です:

f:id:hecomi:20170217022522p:plain

Single Pass にすると描画コストがかなり減ります。ただし全てのシェーダが対応している必要があります(Unity のビルトインシェーダは対応しています)。

壁シェーダ

次に壁シェーダを見ていきます。

Shader "HoloLens/Hole/Wall"
{

Properties
{
    _Mask("Mask", Int) = 1
}

SubShader
{

Tags 
{ 
    "RenderType" = "Opaque" 
    "Queue" = "Geometry-1"
}

CGINCLUDE

#include "UnityCG.cginc"

struct v2f
{
    float4 vertex : SV_POSITION;
    UNITY_VERTEX_OUTPUT_STEREO
};

v2f vert(appdata_base v)
{
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    o.vertex = UnityObjectToClipPos(v.vertex);
    return o;
}

fixed4 frag(v2f i) : COLOR
{
    return 0;
}

ENDCG

Pass
{
    ColorMask 0
    ZWrite On
    ZTest LEqual

    Stencil 
    {
        Ref [_Mask]
        Comp NotEqual
    }

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma target 5.0
    #pragma only_renderers d3d11
    ENDCG
}

}

}

さきほどよりも 1 つキューを上げて Geometry-1 のタイミングで描画します。こちらのステンシルは先ほどと同じマスク値を Ref で参照するよう Mask プロパティを調整し、その上で Comp NotEqual で、マスクをセットした場所「以外」の場所に壁を描くようにしています。

結果

f:id:hecomi:20170218121802g:plain

メリット / デメリット

メリットはとても簡単なことで、背後のオブジェクトのシェーダには手を入れずに、壁と窓だけで完結できる点です。また、壁に穴を開けるのと等価なので直感的です。

デメリットは、壁に近寄って壁がニアクリップで削れてしまうと向こう側が見えてしまいます(それはそれで良いかもしれませんが)。また、空間マッピングがされるまでは背景は全部見える状態になってしまいます。空間マッピングが終わった後に、壁の外のオブジェクトの Visibility を ON にする、といった制御が必要になります。

方法 2

方法 1 は、窓を壁を透かすものとして扱いましたが、もうひとつの方法は、窓の領域のみに背景を描く、というものです。背景のオブジェクト側のシェーダをいじる必要がありますが、前回のデメリットのいくつかは解消されます。

窓シェーダ

窓は方法 1 と同じシェーダを使います。これにより、窓の領域に指定したマスク値がステンシルバッファへと書き込まれます。

背景シェーダ

背景のシェーダの Stencil ブロックで、Comp Equal を使うようにします。先程は NotEqual で、窓「以外」の場所に壁を描く、という形でしたが、今回は Equal で、窓と同じ位置に描く、ということになります。キューは窓の後になるようにして、窓で書き込んだステンシルのマスクを参照できるようにします。窓のあとならいつでも良いので、ここでは通常の Gemetory キューにしています。

Surface シェーダ
Shader "HoloLens/MaskedObject" 
{

Properties 
{
    _Mask("Mask", Int) = 1
    _Color ("Color", Color) = (1,1,1,1)
    _MainTex ("Albedo (RGB)", 2D) = "white" {}
    _Glossiness ("Smoothness", Range(0,1)) = 0.5
    _Metallic ("Metallic", Range(0,1)) = 0.0
}

SubShader 
{

Tags 
{ 
    "RenderType" = "Opaque" 
}

Stencil 
{
    Ref [_Mask]
    Comp Equal
}
    
CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

sampler2D _MainTex;

struct Input 
{
    float2 uv_MainTex;
};

half _Glossiness;
half _Metallic;
fixed4 _Color;

void surf(Input IN, inout SurfaceOutputStandard o) 
{
    fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;
    o.Metallic = _Metallic;
    o.Smoothness = _Glossiness;
    o.Alpha = c.a;
}

ENDCG

}

}
Skybox

通常のスカイボックスとして適用すると Background で描かれてしまうので、別途キューブマップに対応した天球モデルを作成してそれを配置するようにします。

f:id:hecomi:20170218114345p:plain

Shader "HoloLens/MaskedSky"
{

Properties
{
    _Mask("Mask", Int) = 1
    _Tint ("Tint Color", Color) = (.5, .5, .5, .5)
    [Gamma] _Exposure ("Exposure", Range(0, 8)) = 1.0
    [NoScaleOffset] _Cube ("Cubemap (HDR)", Cube) = "grey" {}
}

SubShader
{

Tags 
{ 
    "RenderType" = "Opaque" 
    "Queue" = "Geometry-1"
    "PreviewType" = "Skybox"
}

CGINCLUDE

#include "UnityCG.cginc"

samplerCUBE _Cube;
half4 _Cube_HDR;
half4 _Tint;
half _Exposure;

struct v2f 
{
    float4 vertex : SV_POSITION;
    float3 texcoord : TEXCOORD0;
    UNITY_VERTEX_OUTPUT_STEREO
};

v2f vert(appdata_base v)
{
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.texcoord = v.vertex.xyz;
    return o;
}

fixed4 frag(v2f i) : SV_Target
{
    half4 tex = texCUBE(_Cube, i.texcoord);
    half3 c = DecodeHDR(tex, _Cube_HDR);
    c = c * _Tint.rgb * unity_ColorSpaceDouble.rgb;
    c *= _Exposure;
    return half4(c, 1);
}

ENDCG

Pass
{ 
    Cull Front
    ZWrite On

    Stencil 
    {
        Ref [_Mask]
        Comp Equal
    }

    CGPROGRAM
    #pragma fragmentoption ARB_precision_hint_fastest
    #pragma vertex vert
    #pragma fragment frag
    #pragma target 5.0
    #pragma only_renderers d3d11
    ENDCG
}

}

}

結果

スカイボックス

f:id:hecomi:20170218121801g:plain

オブジェクト

f:id:hecomi:20170218192256g:plain

メリット / デメリット

メリットは、窓の領域のみに描かれるので壁がニアクリップで表示されなくなっても向こう側が見えたりしません。空間マッピングを待つ必要もありません。また、マスク値を分ければ複数の異なる世界を窓から見せることが出来ます。

デメリットとしては、背景のオブジェクト全てにステンシルを適用したシェーダを使わなければならない点と、窓オブジェクトそのものがニアクリップで見えなくなってしまうと、背景も同時に見えなくなってしまう点があげられます。窓に近づきたい場合は、窓の外に Cull Front した箱を置いておくなどの対策が必要になります。

先行事例

HoleLenz

VoxelKei さんの HoleLenz が本日(2017/02/18)より公式の Apps で公開されていてすぐ遊べます!

おそらく方式 2 と同等の方法かなと推測しています。

Portal

ファンメイドの Portal デモです。

おわりに

他にも方法はあると思いますが、取り敢えずこの 2 つがシンプルかなと思いました。ぜひお試しください。

*1:ちなみに上記エントリのスクリーンスペースブーリアンは Deferred かつ重いのでおすすめできません