読者です 読者をやめる 読者になる 読者になる

凹みTips

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

HoloLens で使える Near Clip 表現について解説してみた

Unity Shader C# HoloLens

はじめに

先日、Micorosoft さんのオフィスで Tokyo HoloLens ミートアップ vol.2 が行われ、そこで LT 枠で発表をしてきました。発表は「HoloLens x Graphics 入門」ということで、HoloLens に見られる幾つかの基本的な表現の紹介および解説を行いました。資料は以下になります。

プロジェクトは GitHub に上がっています。

穴空き表現は前回のエントリで解説したので、本エントリではニアクリップ表現についての解説を行いたいと思います。

デモ

黄色が何もしない(= カメラでクリッピングされるのみ)場合、緑が近づくにつれて透明にする表現(~ ホーム画面)、青が近づくにつれポリゴンをバラバラにしながら縮小・透明にする表現(~ Fragments)です。

HoloLens の最適な距離

まず、Unity 上での解説に移る前に、HoloLens でオブジェクトを表示する最適な距離について触れたHoloLens アプリ解説の記事のおさらい及び補足をしたいと思います。

f:id:hecomi:20160205144921j:plain

人間の目が立体視する仕組みについて少し理解する必要があるので見ていきましょう。

片目を瞑って指を目の前に置いてみて欲しいのですが、その指にピントを合わせようとすると奥の景色がボケると思います。逆に奥にピントを合わせると指がボケます。同様に今度は両目を開けた状態で同じように指にピントを合わせようとすると、先程よりは目に力が入った感じがした上で奥側がボケると思います。これはそれぞれ目の水晶体を調整(焦点の調節)したり、両目の向きを内側に寄せて調整(輻輳の調節)したりすることに対応しています。この 2 つの目の調節機構は独立していますが、通常の世界ではリンクしています。そしてこれらから得られる情報に加え、視差(両眼視差、運動視差)や遮蔽、テクスチャなどの情報を統合して、人間は奥行きを知覚しています。

しかしながら HoloLens は光学的に 2.0 m のところに焦点が来るように設計されています*1。一方、御存知の通り、作成するアプリの中では距離を自由に変えることが出来ます。ここで、ものすごい近い場所(50 cm 以下等)にオブジェクトを置いたときを想像して欲しいのですが、この場合は先程試したように目が両側に寄ります。しかしそれぞれの目の焦点は 2.0 m に設定されています。この結果、同じ位置付近にあるリアルなオブジェクトはピントが合わず、バーチャルなオブジェクトだけにピントが合っている状態が生じます。逆に、リアルなオブジェクトにピントを合わせると、バーチャルなオブジェクトのピントがずれます*2

こういった現象が起きると目や頭が疲れたりすることにつながります。これを回避するために、公式のドキュメントではポリゴンが描画される最小距離(Near Clip)を 0.85 m に設定することが推奨されています。また、遠い Near Clip を設定することは他にも恩恵が有り、例えば FOV が狭い関係上、近い距離に置かれたオブジェクトは画面の端の方で片目には映るが、もう一方の目では映らないといったことが回避できたり、近すぎてインタラクションできない、といったことも回避出来るといった点です。利点というよりは、現状のデメリットを最小化出来る、といった方が正しいかもしれません。

既存の表現

では既存のアプリではどういう表現をしているか見てみましょう。

ホーム画面の挙動

ホーム画面のウィンドウやでは近づくにつれ、黒くなる不透明なマテリアルを使用しています。透明ではなく黒ですが、スライドで説明したように実機上では黒は透明として出力されるため、多くのシーンで問題ありません(ポリゴンが更に後ろにある場合にちょっと破綻した見え方になります)。透明にも出来ますが、不透明黒の方が描画のコストが安いです。

Fragments の挙動

Fragments ではちょっとおもしろい表現をしています。近づくとポリゴンがバラバラッと分解されます。Fragments はストーリーの設定(遠隔から犯罪現場を再構築・調査)するというテーマとマッチした表現を作っていてすごいですね。

透明シェーダ

前置きが長くなりましたが、それでは実際に実装を見ていきましょう。実装自体は凝ったことをしなければ難しくはありません。

アルファ

Particle Add シェーダをベースにしてみます。コードの変更点がある場所のみ示します。

Shader "HoloLens/NearClip/AlphaAdditive" {
Properties {
    ...
    _StartDistance("Start Distance", Float) = 1.2
    _EndDistance("End Distance", Float) = 0.85
}

Category {
    ...
    SubShader {
        Pass {
            CGPROGRAM
            ...
            float _StartDistance;
            float _EndDistance;

            v2f vert (appdata_t v)
            {
                v2f o;
                ...
                fixed4 worldPos = mul(unity_ObjectToWorld, v.vertex);
                fixed3 dist = length(_WorldSpaceCameraPos - worldPos);
                fixed t = (_StartDistance - dist) / (_StartDistance - _EndDistance);
                o.color.a *= clamp(1.0 - t, 0.0, 1.0);
                ...
                return o;
            }
            ENDCG 
        }
    }    
}
}

まず、頂点に unity_ObjectToWorld を掛けてワールド座標に変換します。次に、その座標とカメラのワールド座標 _WorldSpaceCameraPos との差を求めると、カメラまでの距離が頂点ごとにわかります。この距離からフェード開始距離、終了距離を使ってどれぐらい透明にするべきかを算出した後、頂点カラーの出力にその値を掛けてあげれば透明になります。

f:id:hecomi:20170326191619g:plain

非常にシンプルです。

ついでに Surface Shader 版も見てみましょう。こちらはアルファは使わずに黒くしてみます。出力する Albedo 値を黒くしただけでは完全に黒にすることは出来ないので、Surface Shader の finalcolor というポスト処理を利用します。

Shader "HoloLens/NearClip/StandardSurface" 
{

Properties 
{
    ...
    _StartDistance("Start Distance", Float) = 1.2
    _EndDistance("End Distance", Float) = 0.85
}

SubShader 
{
    CGPROGRAM
    #pragma surface surf Standard fullforwardshadows finalcolor:nearclip_effect

    ...

    struct Input 
    {
        ...
        float3 worldPos;
    };

    ...
    float _StartDistance;
    float _EndDistance;

    void nearclip_effect(Input IN, SurfaceOutputStandard o, inout fixed4 color)
    {
        fixed3 dist = length(_WorldSpaceCameraPos - IN.worldPos);
        fixed t = (_StartDistance - dist) / (_StartDistance - _EndDistance);
        color *= clamp(1.0 - t, 0.0, 1.0);
    }
    ENDCG
}
...
}

f:id:hecomi:20170326194036g:plain

不透明な頂点・フラグメントシェーダが欲しい場合も、同じようにカラーに距離に応じたパラメタを掛けてあげれば大丈夫です。

ポリゴン分解

次にポリゴン分解を見ていきます。こちらはちょっとやることが多いです。

通常、モデルは頂点を共有しているためバラバラには出来ません。例えば球のそれぞれの頂点をバラバラに動かしても、トゲトゲしたボールにしかなりません。しかしながらジオメトリシェーダを通過させると、ポリゴン毎に処理できるためバラバラにすることが出来ます。

Shader "HoloLens/NearClip/DestructionAdditiveGS"
{

Properties
{
    ...
    [KeywordEnum(Property, Camera)]
    _Method("DestructionMethod", Float) = 0
    _Destruction("Destruction Factor", Range(0.0, 1.0)) = 0.0
    _PositionFactor("Position Factor", Range(0.0, 1.0)) = 0.2
    _RotationFactor("Rotation Factor", Range(0.0, 1.0)) = 1.0
    _ScaleFactor("Scale Factor", Range(0.0, 1.0)) = 1.0
    _AlphaFactor("Alpha Factor", Range(0.0, 1.0)) = 1.0
    _StartDistance("Start Distance", Float) = 0.6
    _EndDistance("End Distance", Float) = 0.3
}

CGINCLUDE

#include "UnityCG.cginc"

#define PI 3.1415926535

...
fixed _Destruction;
fixed _PositionFactor;
fixed _RotationFactor;
fixed _ScaleFactor;
fixed _AlphaFactor;
fixed _StartDistance;
fixed _EndDistance;

struct appdata_t 
{
    float4 vertex : POSITION;
    float4 normal : NORMAL;
    fixed4 color : COLOR;
    float2 texcoord : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct g2f
{
    float4 vertex : SV_POSITION;
    fixed4 color : COLOR;
    ...
};

appdata_t vert(appdata_t v)
{
    return v;
}

[maxvertexcount(3)]
void geom(triangle appdata_t input[3], inout TriangleStream<g2f> stream)
{
    float3 center = (input[0].vertex + input[1].vertex + input[2].vertex) / 3;
    
    float3 vec1 = input[1].vertex - input[0].vertex;
    float3 vec2 = input[2].vertex - input[0].vertex;
    float3 normal = normalize(cross(vec1, vec2));

#ifdef _METHOD_PROPERTY
    fixed destruction = _Destruction;
#else
    float4 worldPos = mul(unity_ObjectToWorld, float4(center, 1.0));
    float3 dist = length(_WorldSpaceCameraPos - worldPos);
    fixed destruction = clamp((_StartDistance - dist) / (_StartDistance - _EndDistance), 0.0, 1.0);
#endif

    fixed r = 2 * (rand(center.xy) - 0.5);
    fixed3 r3 = fixed3(r, r, r);

    [unroll]
    for (int i = 0; i < 3; ++i)
    {
        appdata_t v = input[i];

        g2f o;
        ...

        v.vertex.xyz = (v.vertex.xyz - center) * (1.0 - destruction * _ScaleFactor) + center;
        v.vertex.xyz = rotate(v.vertex.xyz - center, r3 * destruction * _RotationFactor) + center;
        v.vertex.xyz += normal * destruction * _PositionFactor * r3;
        o.vertex = UnityObjectToClipPos(v.vertex);

        o.color = v.color;
        o.color.a *= 1.0 - destruction * _AlphaFactor;
        ...

        stream.Append(o);
    }
    stream.RestartStrip();
}

...

ENDCG

SubShader
{

...

Pass
{
    CGPROGRAM
    #pragma vertex vert
    #pragma geometry geom
    #pragma fragment frag
    ...
    #pragma multi_compile _METHOD_PROPERTY _METHOD_CAMERA
    ENDCG
}

}

}

ジオメトリシェーダは頂点シェーダの後に実行され、頂点シェーダは頂点ごとに処理され一方、ジオメトリシェーダではプリミティブ単位で処理されます。具体的にここではポリゴンを入力として受け取り、ポリゴンを出力しています。つまり頂点シェーダではできなかった、ポリゴンの中心点を求めたり、法線を 2 辺から求めたり出来ます。

処理の内容としては、位置・回転・スケール・アルファそれぞれにどれくらいバラバラ度合いを適用するかがパラメタになっており、そのパラメタを使ってポリゴンをスケール、回転、位置の順でバラバラにしています。バラバラ度合いのパラメタは、ポリゴンの中心点とカメラの距離を調べて算出しています。デバッグ用に _METHOD_PROPERTYParameter を指定した場合は、インスペクタからどれくらいバラバラにするか指定できるようになっています。

f:id:hecomi:20170327000629g:plain

おまけ:ジオメトリシェーダが使えない環境

HoloLens ではジオメトリシェーダを使えば良いのですが、スマホなどジオメトリシェーダが使えない環境でも事前にポリゴンを処理して分解してしまえば同じような表現は可能です。具体的には、事前にポリゴンの頂点を頂点配列へ詰め直し、インデックス配列をそれに従い並び替え、ポリゴンの中心点やポリゴンの ID を余った UV へと書き込んでおく、という形です。

スクリプト

計算が重いのでキャッシュするようにしています。

using UnityEngine;
using System.Collections.Generic;

[RequireComponent(typeof(MeshFilter))]
public class NearDestructionEffect : MonoBehaviour 
{
    static Dictionary<Mesh, Mesh> destructableMeshTable = new Dictionary<Mesh, Mesh>();

    void Start() 
    {
        var meshFilter = GetComponent<MeshFilter>();
        meshFilter.mesh = GetDestructableMesh(meshFilter); // clone if needed
        var mat = GetComponent<Renderer>().material; // just clone
    }

    Mesh GetDestructableMesh(MeshFilter meshFilter)
    {
        Mesh mesh;
        destructableMeshTable.TryGetValue(meshFilter.sharedMesh, out mesh);
        if (!mesh) {
            mesh = GenerateDestructableMesh(meshFilter);
            destructableMeshTable.Add(meshFilter.sharedMesh, mesh);
        }
        return mesh;
    }

    Mesh GenerateDestructableMesh(MeshFilter meshFilter)
    {
        var sharedMesh = meshFilter.sharedMesh;
        var sharedIndices = sharedMesh.GetIndices(0);

        var vertices = new List<Vector3>();
        var indices = new int[sharedIndices.Length];
        var normals = new List<Vector3>();
        var tangents = new List<Vector4>();
        var uv1 = new List<Vector2>();
        var uv2 = new List<Vector2>();
        var uv3 = new List<Vector3>();

        for (int i = 0; i < sharedIndices.Length / 3; ++i) {
            for (int j = 0; j < 3; ++j) {
                int n = 3 * i + j;
                int index = sharedIndices[n];
                indices[n] = n;
                vertices.Add(sharedMesh.vertices[index]);
                normals.Add(sharedMesh.normals[index]);
                tangents.Add(sharedMesh.tangents[index]);
                uv1.Add(sharedMesh.uv[index]);
                uv2.Add(new Vector2(i, i));
                uv3.Add((
                    (sharedMesh.vertices[sharedIndices[3 * i + 0]]) + 
                    (sharedMesh.vertices[sharedIndices[3 * i + 1]]) + 
                    (sharedMesh.vertices[sharedIndices[3 * i + 2]])) / 3f);
            }
        }

        var mesh = new Mesh();
        mesh.name = sharedMesh.name + " (Destructable)";
        mesh.SetVertices(vertices);
        mesh.SetIndices(indices, MeshTopology.Triangles, 0);
        mesh.SetNormals(normals);
        mesh.SetTangents(tangents);
        mesh.SetUVs(0, uv1);
        mesh.SetUVs(1, uv2);
        mesh.SetUVs(2, uv3);
        mesh.RecalculateBounds();

        return mesh;
    }
}

シェーダ

ID と中心座標を取り出して先ほどと同じように計算します。

...

struct appdata_t 
{
    float4 vertex : POSITION;
    float4 normal : NORMAL;
    fixed4 color : COLOR;
    float2 texcoord : TEXCOORD0;
    fixed2 texcoord1 : TEXCOORD1;
    fixed3 texcoord2 : TEXCOORD2;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct v2f 
{
    float4 vertex : SV_POSITION;
    fixed4 color : COLOR;
    ...
};

inline int getId(fixed2 uv1)
{
    return (int)(uv1.x + 0.5);
}

v2f vert(appdata_t v)
{
    v2f o;
    ...

    fixed r = 2 * (rand(v.texcoord1) - 0.5);
    fixed3 r3 = fixed3(r, r, r) * PI;
    fixed3 center = v.texcoord2;

#ifdef _METHOD_PROPERTY
    fixed destruction = _Destruction;
#else
    fixed4 worldPos = mul(unity_ObjectToWorld, float4(center, 1.0));
    fixed3 dist = length(_WorldSpaceCameraPos - worldPos);
    fixed destruction = clamp((_StartDistance - dist) / (_StartDistance - _EndDistance), 0.0, 1.0);
#endif

    v.vertex.xyz = (v.vertex.xyz - center) * (1.0 - destruction * _ScaleFactor) + center;
    v.vertex.xyz = rotate(v.vertex.xyz - center, r3 * destruction * _RotationFactor) + center;
    v.vertex.xyz += center.xyz * destruction * _PositionFactor * r;
    o.vertex = UnityObjectToClipPos(v.vertex);
    ...

    o.color = v.color;
    o.color.a *= 1.0 - destruction * _AlphaFactor;
    ...

    return o;
}

...

結果は全くおなじになりますが、途中のフレームで新規のポリゴンの分解処理を走らせるとプチフリします。

おわりに

紹介した以外にも色々なニアクリップエフェクトが考えられます。例えばボクセル調のアプリであれば、ボクセル毎に頂点に ID を振っておいてボクセル単位で分解したり、近寄った場所付近の頂点だけ奥側へ移動してめり込まないようにする、とかです。またエフェクトをゲーム性に活かすことも出来、例えば Fragments では分解エフェクトで手前のポリゴンを分解できることを利用して、近寄ることで分解させた先にあるものを見させる、みたいなものもありました。是非皆さんも工夫して色々なエフェクトを作ってみてください。

*1:可変にするためには現状では様々な技術的な制約があるため将来のデバイスに期待です

*2:ちなみに VR では現実のものは移っていないので、目の焦点は常に一定に保たれています