凹みTips

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

HoloLens 用の Body-Locked な UI を作ってみた

はじめに

ホーム画面の UI の用に、ある程度の遊びとディレイを持って視界に追従してくる Body-Locked な UI を実現したい時のために、HoloToolkit-Unity には Tagalong というコンポーネントが用意されているのですが、その挙動が自分のイメージとちょっと違ったので自作してみました。

デモ

コード

github.com

Scripts/BodyLockes.cs をダウンロードして適当なオブジェクトにアタッチしてください。

Body-Locked

そもそも、Body-Locked とはなんぞや、から見ていきましょう。先日の HoloLens Meetup vol.2 で高橋 忍さんが話されていた分類が分かりやすかったので紹介します。

HoloLens でのオブジェクトの配置は大きく 3 種類にわけられ、Body-Locked はその内の一つという位置づけです。

  • World-Locked
    • 現実の位置に重ねて表示
  • Display-Locked
    • ディスプレイ上に固定
  • Body-Locked
    • 視界の範囲内で遅延させて表示

ホーム画面の要素で考えてみると、配置後のウィンドウや Holograms で配置したオブジェクトは World-Locked、カーソルが Display-Locked、配置前のウィンドウやスタートメニューが Body-Locked にあたると思われます。Display-Locked な表示はユーザの不快感を引き起こす可能性が高いため、基本的には避けたほうが良いと公式でも述べられています。

よって、ワールドで配置される以外の UI などはこの Body-Locked の動きを利用するのが良い選択肢となります。

Tagalong について

この Body-Locked のレイとして、SimpleTagalong コンポーネントが Holograms 210 で解説されています。

SimpleTagalongBillboard コンポーネントを併せて使うことで、上述の Body-Locked な表示を実現します。ただ Simple という名を冠している通り、基本的にはコリジョンなどは見ずに視線の先にディレイをもって追従してくるだけの機能を有したものになります。そこで、これを発展させたものとして SimpleTagalong を継承した Tagalong が HoloToolkit-Unity には含まれています。以下のような機能が追加されています。

  • 視錐台に入るようスケールする
  • 他のオブジェクトや空間メッシュの前面へ来るようにする

以下のエントリでより詳しく説明されていましたので、詳細を知りたい場合はこちらをご覧ください。

kzonag.hatenablog.com

作成の動機

ただ、スタートメニューのような動きを Tagalong に期待していたのですが、どうやら違うようで、個人的にはコリジョン解決の動きや、視界外へ行ったときの移動などが余り好みではありませんでした。またコードも関連するコンポーネントが多く、カスタマイズも面倒だったので自前で作ることにしました。

設計の指針としては次のような形です。

  • コリジョン避け
    • コリジョン解決はなるべく速めに動く
    • コリジョンを避けた時、視線との角度が浅くなければ貼り付く(~ ウィンドウ配置の挙動)
    • 天井 / 床は頭の向きに沿って回転、それ以外は上向き
  • 視界の中心へ移動
    • 視界から外れた時は視界の端まで移動ではなく、一度中心まで移動してくる
    • 視界が大きく動かない間はその場に留まる
  • 短いコード

コードと解説

次のようになりました。

コード

using UnityEngine;

namespace Hecomi.HoloLensPlayground
{

public class BodyLocked : MonoBehaviour 
{
    #region(Parameters)
    [Header("Basics")]
    [SerializeField]
    float maxDistance = 2f;

    [SerializeField]
    float minDistance = 0.5f;

    [SerializeField]
    float minScaleRatio = 0.5f;

    [SerializeField]
    float moveThresholdAngle = 15f;

    [Header("Smoothness")]
    [SerializeField]
    [Range(0f, 1f)]
    float directionSmoothness = 0.94f;

    [SerializeField]
    [Range(0f, 1f)]
    float distanceSmoothness = 0.8f;

    [SerializeField]
    [Range(0f, 1f)]
    float rotationSmoothness = 0.96f;

    [Header("Collision")]
    [SerializeField]
    bool checkCollision = true;

    [SerializeField]
    float radius = 0.1f;

    [SerializeField]
    float floorCeilingAngle = 80f;

    [SerializeField]
    float maxPasteAngle = 40f;

    [SerializeField]
    float offsetFromCollision = 0.1f;

    [SerializeField]
    LayerMask collisionLayerMask = 1 << 31;

    [SerializeField]
    int rayNum = 3;

    [SerializeField]
    float noise = 0.03f;
    #endregion

    #region(Private members)
    bool isMoving_ = false;
    Vector3 initScale_ = Vector3.one;
    Vector3 direction_ = Vector3.forward;
    float distance_ = 2f;
    Quaternion targetRotation_ = Quaternion.identity;
    float targetDistance_ = 2f;
    #endregion

    void Start()
    {
        var camera = Camera.main.transform;

        initScale_ = transform.localScale;
        direction_ = camera.forward;
        distance_ = targetDistance_ = maxDistance;
        targetRotation_ = Quaternion.LookRotation(camera.forward, Vector3.up);

        transform.position = camera.position + (direction_ * distance_);
        transform.rotation = targetRotation_;
    }

    void UpdateDirection()
    {
        var camera = Camera.main.transform;
        var angle = Vector3.Angle(direction_, camera.forward);

        if (!isMoving_) {
            if (Mathf.Abs(angle) > moveThresholdAngle) {
                isMoving_ = true;
            }
        } else {
            if (angle < 1f) {
                isMoving_ = false;
            }
            var cameraForwardRot = Quaternion.LookRotation(camera.forward, Vector3.up);
            var directionRot =  Quaternion.LookRotation(direction_, Vector3.up);
            direction_ = Quaternion.Slerp(directionRot, cameraForwardRot, 1f - directionSmoothness) * Vector3.forward;
        }

        targetDistance_ = maxDistance;
        targetRotation_ = Quaternion.LookRotation(camera.forward, Vector3.up);
    }

    void UpdateCollision()
    {
        if (!checkCollision) return;

        var camera = Camera.main.transform;
        float averageDistance = 0f;
        var averageNormal = Vector3.zero;
        int hitNum = 0;

        for (int i = 0; i < rayNum; ++i) {
            RaycastHit hit;
            var noiseRadius = Random.Range(0f, noise) * radius;
            if (Physics.SphereCast(camera.position, radius + noiseRadius, direction_, out hit, maxDistance, collisionLayerMask)) {
                averageDistance += hit.distance + (radius + noiseRadius) - offsetFromCollision;
                averageNormal += hit.normal;
                ++hitNum;
            }
        }

        if (hitNum > 0) {
            averageDistance /= hitNum;
            averageNormal /= hitNum;

            var axis = Vector3.up;
            if (Mathf.Abs(Vector3.Dot(averageNormal, axis)) > Mathf.Cos(floorCeilingAngle * Mathf.Deg2Rad)) {
                axis = camera.up;
            }

            var forward = camera.forward;
            if (Vector3.Angle(direction_, -averageNormal) < maxPasteAngle) {
                forward = -averageNormal;
            }

            targetDistance_ = averageDistance; 
            targetRotation_ = Quaternion.LookRotation(forward, axis);
        }
    }

    void LateUpdate()
    {
        UpdateDirection();
        UpdateCollision();

        distance_ = Mathf.Max(distance_ + (targetDistance_ - distance_) * distanceSmoothness, minDistance);
        var scaleFactor = (distance_ - minDistance) / (maxDistance - minDistance);

        transform.position = Camera.main.transform.position + (direction_ * distance_);
        transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation_, 1f - rotationSmoothness);
        transform.localScale = initScale_ * (1f - minScaleRatio * (1f - Mathf.Clamp(scaleFactor, 0f, 1f)));
    }
}

}

インスペクタ

f:id:hecomi:20170401171555p:plain

簡単な説明

  • Basics
    • Max Distance
      • 最大距離(これより遠くに行かない)
    • Min Distance
      • 最も近い距離(コレより近くに来ない)
    • Min Scale Ratio
      • 近くなった時にどれくらいスケールするか(1.0: デフォルトの大きさ)
    • Move Threshold Angle
      • 視線方向とオブジェクトまでの方向とのなす角がこれ以上になったら追従開始
  • Smoothness
    • Direction Smoothness
      • 視界の周りの回転の追従度合い(0.0 になるほど速く、1.0 になるほど滑らか)
    • Distance Smoothness
      • 距離方向の移動の滑らかさ
    • Rotation Smoothness
      • オブジェクト自身の回転の滑らかさ
  • Collision
    • Check Collision
    • Floor Ceiling Angle
      • 天井 / 床とみなす角度
    • Max Paste Angle
      • 視線と壁の法線のなす角がこれ以下なら貼りつく
    • Offset From Collision
      • 壁からのオフセット距離
    • Collision Layer Mask
      • レイヤマスク(デフォルトは壁(1 << 31)のみ、他のオブジェクトにも当たりたかったら適切なマスクを指定)
    • Ray Radius
      • レイ(スフィアキャスト)の半径
    • Ray Num
      • 壁の法線を調べる際のレイの数
    • Ray Noise
      • 壁の法線を調べる際に乗せるレイのランダム度合い

おまけ: オクルージョンを別表示する UI 用シェーダ

壁にめり込むと見えなくなるのが何となくイケてなく感じたので、めり込んだ場所は別の色で表示するようにしました。ただ、このままだとカーソルが乗っても同じように遮蔽表現になってしまうのでもう少し工夫が必要だと思います。

Shader "HoloLens/UI/SpriteWithOcclusion"
{

Properties
{
    [PerRendererData] _MainTex("Sprite Texture", 2D) = "white" {}
    _Color("Tint", Color) = (1, 1, 1, 1)
    [MaterialToggle] PixelSnap("Pixel snap", Float) = 0
    _OcclusionColor("OcclusionColor", Color) = (1, 1, 1, 1)
    _FadeStartDistance("Start Distance", Float) = 1.2
    _FadeEndDistance("End Distance", Float) = 0.85
}

SubShader
{

Tags
{ 
    "Queue"="Transparent" 
    "IgnoreProjector"="True" 
    "RenderType"="Transparent" 
    "PreviewType"="Plane"
    "CanUseSpriteAtlas"="True"
}

CGINCLUDE

#include "UnityCG.cginc"

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

struct v2f
{
    float4 vertex   : SV_POSITION;
    fixed4 color    : COLOR;
    float2 texcoord  : TEXCOORD0;
    UNITY_VERTEX_OUTPUT_STEREO
};

float _FadeStartDistance;
float _FadeEndDistance;
fixed4 _Color;
fixed4 _OcclusionColor;

v2f vert(appdata_t v)
{
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.texcoord = v.texcoord;
    o.color = v.color * _Color;
#ifdef PIXELSNAP_ON
    o.vertex = UnityPixelSnap (o.vertex);
#endif

    float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
    float3 dist = length(_WorldSpaceCameraPos - worldPos);
    fixed t = (_FadeStartDistance - dist) / (_FadeStartDistance - _FadeEndDistance);
    o.color.a *= clamp(1.0 - t, 0.0, 1.0);

    return o;
}

sampler2D _MainTex;
sampler2D _AlphaTex;

fixed4 SampleSpriteTexture(float2 uv)
{
    fixed4 color = tex2D (_MainTex, uv);

#if ETC1_EXTERNAL_ALPHA
    color.a = tex2D (_AlphaTex, uv).r;
#endif

    return color;
}

fixed4 frag(v2f IN) : SV_Target
{
    fixed4 c = SampleSpriteTexture(IN.texcoord) * IN.color;
    c.rgb *= c.a;
    return c;
}

fixed4 occlusion_frag(v2f IN) : SV_Target
{
    fixed4 c = SampleSpriteTexture(IN.texcoord) * IN.color * _OcclusionColor;
    c.rgb *= c.a;
    return ;
}

ENDCG

Pass
{
    Cull Off
    Lighting Off
    ZWrite Off
    ZTest LEqual
    Blend One OneMinusSrcAlpha

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma target 2.0
    #pragma multi_compile _ PIXELSNAP_ON
    #pragma multi_compile _ ETC1_EXTERNAL_ALPHA
    ENDCG
}

Pass
{
    Cull Off
    Lighting Off
    ZWrite Off
    ZTest Greater
    Blend One OneMinusSrcAlpha

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment occlusion_frag
    #pragma target 2.0
    #pragma multi_compile _ PIXELSNAP_ON
    #pragma multi_compile _ ETC1_EXTERNAL_ALPHA
    ENDCG
}

}

}

おわりに

自分の思い通りの動きになるよう、ご自由に改造してお使いください(NYSL ライセンスです)。