はじめに
ホーム画面の UI の用に、ある程度の遊びとディレイを持って視界に追従してくる Body-Locked な UI を実現したい時のために、HoloToolkit-Unity には Tagalong
というコンポーネントが用意されているのですが、その挙動が自分のイメージとちょっと違ったので自作してみました。
デモ
コード
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 で解説されています。
SimpleTagalong
は Billboard
コンポーネントを併せて使うことで、上述の Body-Locked な表示を実現します。ただ Simple という名を冠している通り、基本的にはコリジョンなどは見ずに視線の先にディレイをもって追従してくるだけの機能を有したものになります。そこで、これを発展させたものとして SimpleTagalong
を継承した Tagalong
が HoloToolkit-Unity には含まれています。以下のような機能が追加されています。
- 視錐台に入るようスケールする
- 他のオブジェクトや空間メッシュの前面へ来るようにする
以下のエントリでより詳しく説明されていましたので、詳細を知りたい場合はこちらをご覧ください。
作成の動機
ただ、スタートメニューのような動きを 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))); } } }
インスペクタ
簡単な説明
- Basics
- Max Distance
- 最大距離(これより遠くに行かない)
- Min Distance
- 最も近い距離(コレより近くに来ない)
- Min Scale Ratio
- 近くなった時にどれくらいスケールするか(1.0: デフォルトの大きさ)
- Move Threshold Angle
- 視線方向とオブジェクトまでの方向とのなす角がこれ以上になったら追従開始
- Max Distance
- Smoothness
- Direction Smoothness
- 視界の周りの回転の追従度合い(0.0 になるほど速く、1.0 になるほど滑らか)
- Distance Smoothness
- 距離方向の移動の滑らかさ
- Rotation Smoothness
- オブジェクト自身の回転の滑らかさ
- Direction Smoothness
- Collision
おまけ: オクルージョンを別表示する 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 ライセンスです)。