はじめに

Looking Glassのラージサイズを購入して、ユニティちゃんを踊らすなど定番の儀式をしたのは、もう約1年前の話になりますね。

今回はその後の話、LookingGlassでアクアリウムを作ってみた話になります。

受付システム

弊社の受付では、3Dのキャラがお客様へ応対を行うシステムを自社で開発、テスト的に運用しています。

この受付システムの横にLooking Glassを配置して何か出来ないかと挑戦してみました。

受付システムについての開発裏話はこちら

clipboard.png

呼び出し先確認

第一弾。

受付システムで弊社社員を呼び出した際に社員の3Dモデルと名前をLookingGlassに表示してみました。

全社員3Dスキャンしたので見る方向を変えるとしっかり3Dで見る事ができます。

社員転がし

第二弾、一発ネタです。

公式のLeapMotionのサンプルを改造しました。

球体を社員の3Dモデルに置き換えました。

とてもシュールな絵というか、大丈夫なのかコレ。まぁいいかというノリでデプロイされました。

アクアリウムを作る

実用性のあるものからネタまでLooking Glassで作ってきたわけですが……

第三弾は、熱帯魚の水槽的なアプリ(アクアリウム)を作ってみました。

こういったガジェット向けのアプリでは定番中の定番ですね。

大阪に行けばたこ焼きを食べるように、Looking Glassを購入したらアクアリウムをやりたくなる。そういうもんだと思います。

大阪といえばたこ焼きですが、元々は天下茶屋にある店がラヂオ焼きに明石焼きの具材であるタコを入れたのが発祥のようです。
​
つまり明石焼きが祖先、というわけで神戸方面に来られた際は是非「明石焼き」も食べて見てください。オススメです!
​
(諸説あり/writte by 兵庫県民)

閑話休題……

ということで本記事の主体は「弊社受付システムにアクアリウムを入れてみた」がここから始まります。

Looking GlassのSDKインストールや初期設定などは前回の記事を参照ください。

スペック

本記事で使用したPCスペックは以下の通りです。

CPUi7-8700
GPURTX2070
RAM32GB
Unity2018.4.5

2画面出力

Looking Glass側の実装に入る前に本機能の特徴、2画面出力について簡単に記述します。

本機能は50インチフルHDモニタを縦に配置したメイン画面と横に置かれた4KのLooking Glassというデュアルモニタ構成になっています。

「設置スペースの都合」と「PC間通信したくない!!」という理由で1台のPCから出力するに至りました。

clipboard.png

Unityは複数モニタ出力に対応しているので、その機能をうまく利用しました。

Gameビューには左上にモニタ切り替えタブがあります。

clipboard.png

Camera側にもTargetDisplayからどのモニタに映すか設定ができます。

clipboard.png

以上の設定で、Looking Glass側のカメラをDisplay2にしてやることで2画面出力ができます。

すごい簡単!! Unityすごい!!

clipboard.png

Looking Glassを接続しなくても受付システムが稼働するようにしたいので、下記コードでLooking Glassが接続されている場合のみ出力するようにしています。

        /// <summary>
        /// LookingGlassを映しているカメラ
        /// </summary>
        [SerializeField] private Camera _holoPlayCapture;
        
        /// <summary>
        /// LookingGlassが接続されているか?
        /// </summary>
        private bool _isConnect = false;

        /// <summary>
        /// LookingGlassの解像度(横)
        /// </summary>
        private const int LOOKINGGLASS_WIDTH  = 3840;
        
        /// <summary>
        /// LookingGlassの解像度(縦)
        /// </summary>
        private const int LOOKINGGLASS_HEIGHT = 2160;

        private void Awake()
        {
            //LookingGlassの画面をアクティベート
            //※Editor上では効かない。
            Display[] displays = Display.displays;
            for (int i = 0; i < displays.Length; i++)
            {
                if (   displays[i].systemWidth  != LOOKINGGLASS_WIDTH 
                    || displays[i].systemHeight != LOOKINGGLASS_HEIGHT) continue;
                displays[i].Activate();
                _holoPlayCapture.targetDisplay = i;
                _isConnect = true;
            }
            //ゲームオブジェクトはデフォルトOffにしておき、接続されていればOnにする。
            _holoPlayCapture.gameObject.SetActive(_isConnect);
        }

水槽背景の用意

水槽の背景オブジェクトを用意します。まずは取材です。

新宿のサブナード地下街にアクアリウム系の商品を扱う店があるのでそこでみてきます。

最近のリアル水槽はFogも実装できるんですね……すごい……。

clipboard.png

すごい尻尾してる……優雅感すごい。

clipboard.png

〜〜 取材終わり 〜〜

Asset

取材の結果、背景に欲しい要素は下記になりました。

  • 水草
  • 気泡
  • 枯れ木

これだけあれば良い感じの水槽になるでしょう!

岩と苔、枯れ木

配置する物の中で一番大物な岩を買いにAssetStoreへ向かいます。

幸いな事にAssetStoreでは良い感じの岩がたくさん無料で出されています。 良い感じの岩をDLして配置します。

clipboard.png

岩を配置したら次に欲しいのが苔です。

草のように1本1本配置するには負荷が高すぎます。そして配置する手間も掛かります。

岩の形状を見て苔が生えそうなところに、自動でいい感じに苔を生やすの方法が楽ですよね〜

なので手法はよくある雪を積もらせるShaderでなんとかします。

これまではShader Forgeをよく使ってましたが、サポート終わってから何年使ってるんだよって話なので別の方法で作りたいと思います。

ShaderGraphはScriptableRenderPipeline向けのShaderしか生成できないので、今回はAmplify Shader Editorを使います。

内容としては雪のShaderと同じようにWorld NormalのYを見て岩と苔のShaderを貼り分ける感じです。

clipboard.png

苔のテクスチャが手抜きなのでタイリング感見えてますけど、とりあえず求めてるものは出来ました。(下記画像は円中にShaderを割り当ててます)

clipboard.png

これで苔のテクスチャを植物園とかに撮りに行けばいけるかな?と思ったのですがなんか微妙。ディテール不足感あります。

何かいい方法がないかなぁと考えていた矢先、Book Of The Dead: Environmentを見つけました。水中の苔ではないですが、きっと良い感じの苔があることは間違い無いでしょう。

clipboard.png

さて、DLを……ん?

clipboard.png

ん!?

ライセンスが「Unity アセットストア EULA」と「Unity Companion License」……

Unityさん、Unityさんもしかしてコレの素材使っても良いんですか!まじか、まじかよ、ありがとうUnity!

というわけで方向性変更です!

ここまでシーンに設置した岩と苔はぶん投げます、捨てます。

Book Of The Dead: Environmentから良さげな素材をおいしく頂きます。

良い感じの岩と枯れ木をピックアップしてシーンに配置したのがこちら。

clipboard.png

苔がもう少し欲しいな感がありますが、クオリティは流石 Made In Megascans

この配置を下地に突っ走ります。

水草

水草……水草が欲しい……が、とりあえず地上の草を生やしてもそれっぽく見えないかな?

Book Of The Dead: Environmentからテキトーに見繕った草をシーンに配置したのがこちらです。

clipboard.png

きっと水草に見えます。これは水草です。間違い無い。

気泡

水中に草が生えてると光合成で酸素が生まれるので気泡も生まれます。というわけで気泡を再現します。

Underwater FXを使用します。

このアセットには気泡のShurikenパーティクルが含まれてるのでこれを利用します。草の辺りに仕込んで終わりです。

clipboard.png

いい感じです!!

Fog

Looking Glassでは綺麗な立体に見えるスイートスポットが狭いので、奥にオブジェクトがあると綺麗に見えません。

そこでFogを焚いてぼかします。これだけで奥が綺麗に見えるようになりました。

clipboard.png

スイートスポットの手前側はそもそもオブジェクトを置かないという方法で対処しています。

clipboard.png

背景の軽量化

背景無事完成!と思ったのでドローコールを見てみます。

……5桁あるわ、おいおい死んだわ。

いや、いくら高品質なMegascansライブラリつかったとはいえ5桁は盛りすぎでしょ。なんでこうなった???

助けて!インターネット!

へ〜〜〜

つまりこの子(LookingGlass)は初期設定で、画面出力に45枚も毎回レンダリングしてると。

VRで左右2枚分レンダリングするのが重いからSingle-Pass Stereo Renderingしてるのに、この子はForty Five Pass Renderingをしてると?

なるほど……

ドローコール絶対撲滅する運動がここから始まります。

MeshBaker

まずMeshを結合します。そうすれば岩全体を1ドローコール(LookingGlass的には45ドローコール)で描ける筈です。

Meshを結合する前にBook Of The Dead: EnvironmentのAssetはLODが設定されてるので絵を見ながら必要品質レベルのMeshを抜き出してLODを無効化しておきます。

MeshBakerは定番のMesh、Materialを結合して1つにしてくれるアセットです。シロちゃんもオススメのAssetです。​

最近はVRchatなどでドローコールがより意識されてるのでそちら方面でもよく使われているようです。

なので使い方はネットですぐ見つかるので、説明は割愛します。

使い勝手と軽量化のバランスを取った結果、左右の岩毎に透過Shaderと非透過Shader組で分けました。これで1passのドローコールは4となります。

clipboard.png

Mesh結合で3桁台後半のドローコールになりました。

LightBake

さらにドローコールを消費してる悪い子を探します。探した結果、影の描画でドローコールが増えていました。

今回に限ってはリアルタイムなライトとか影なんて要らん!ということでLightmap Staticにして影を焼き込みます。

通常のStaticにしていないのは、登場時の演出で岩をせり上げたいためLightmapのみStaticにしています。

clipboard.png

これでドローコール3桁台前半。勝ったな。

魚の用意

背景が一段落したので魚を配置します。魚を買いにAsset Storeへ向かいます。

どういったアセットが最適かを検討。

  • 熱帯にいるカラフル(彩度高め)なものが良さそう
  • メダカとか透明感あるのは負荷的にやりたくない
  • 小さい魚はSSS(SubSurface Scattering)とか欲しくなるヤツなのでやめる
  • 大きいLookingGlassとはいえ美ら海水族館でもないので巨大な魚は無理
  • LookingGlassで綺麗に立体に見える範囲が狭いので錦鯉とかも厳しそう

というわけで熱帯カラフル魚を探します。

Colorful Sea-Fish Packを買いました。

clipboard.png

これを大量にばらまいて泳がせます。

SwarmAgent

大量にばらまいて泳がせるということは、それっぽい魚群の動きにしたいですね。

バラバラに泳がしてもいいのですがそこはスイミーみたいに同じ種類は同じ方向に固まって動いて欲しいという欲望です。

群体シミュレーションを手っ取り早く実装したい。

AssetStoreにありました!

Swarm Agentを買いました。

clipboard.png

魚の種類毎に魚群を構成します。下の白正方形と上の白正方形の上下間が魚の移動エリアになります。(厳密ではなく時々はみ出ます。)

魚はSwarmFocusオブジェクトを追いかけるので、SwarmFocusを画像青枠エリアで適当に動かします。

clipboard.png

これで画像のように魚の種類毎に同じ所に移動しようとします。

魚毎の動きもSwarm Agentの設定でランダム性を付与できるので、ランダム性を強くすることで群のまとまりも在りつつ、自然な遊泳になっています。

clipboard.png

魚の軽量化

魚を試しに40匹ほど放ちました。魚一匹あたり1ドローコールとすると

40 * 45 = 1800 ドローコール

ドローコール祭りが始まってしまったのでこれの軽量化に努めていきます……

まず、ドローコールもやばいのですが魚の動きがSkindMeshRendererでAnimatorを使用して動いてるのも負荷になっています。 まずはモーションの対策から行います。

Vertex Animation Texture

魚の動きは人と比べて単純なのでVAT(Vertex Animation Texture)で動かそうと思います。

VATとはMesh頂点の動きをTextureに格納してアニメーションをさせる方法です。

VATを使う事でSkinの処理やAnimatorの処理から解放され、さらに頂点アニメーションはGPU側で処理してくれるようになります。

アニメーション尺が長く、頂点数が多くなるとTextureサイズが増えていくという部分に気をつける必要がありますが、今回の魚には最適な手法です。

Unity用にAnimatorのAnimationからTextureを生成して、それを再生するShaderまでGitHubにあったので有り難く使わせていただきます。

https://github.com/sugi-cho/Animation-Texture-Baker

というわけで焼いた物がこちらです。

clipboard.png

魚1種類毎に、512 x 128のTexture2枚で収まりました。

このまま大量生成してしまうと全く同じ再生タイミングになってしまうので、delta timeに適当な乱数を入れてやるとバラバラの動きになります。

ついでにサイズもランダムでバラバラにすると良い感じになります。

        /// <summary>
       /// ShaderのプロパティID
       /// </summary>
       private readonly int _propertyId = Shader.PropertyToID("_DT");

       /// <summary>
       /// 魚の初期化
       /// </summary>
       /// <param name="fishObj"></param>
       /// <param name="scale"></param>
       private void fishInit(GameObject fishObj,Vector2 scale)
      {
           fishObj.GetComponentInChildren<Renderer>().material.SetFloat(_propertyId, Random.Range(0, 2f));

           fishObj.transform.localScale *= Random.Range(scale.x, scale.y);
      }

影の無効化

魚に光と影を与えるとドローコールが爆発します。

幸い、Animation-Texture-Bakerに含まれていたShaderはUnlit系なのでこのまま光と影は無効にしましょう。

Cast ShadowをOff、Receive ShadowもOff、Layerを分けてライトの影響を受けないようにします。

ともあれ、ドローコールは滅ぶべきであると考える次第である。

clipboard.png

GPU Instancing

ここまででやっても1魚1ドローコールであることは変わらないので

40 * 45 = 1800 ドローコール

のままです。ドローコールは滅ぶべき存在のためこれを数百程度まで落としていきます。

先ほどのVATの採用で固形Meshを描画している扱いなので、それを利用して魚の種類毎に1ドローコールで全匹描画していきます。

同一Mesh、同一MaterialであればGPU Instancingが使えるので今のVAT ShaderをGPU Instancing対応させます。

対応させたコードは以下の通りです。

Shader "Unlit/TextureAnimPlayerInstancing"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
		_PosTex("position texture", 2D) = "black"{}
		_NmlTex("normal texture", 2D) = "white"{}
		_Length ("animation length", Float) = 1
		[Toggle(ANIM_LOOP)] _Loop("loop", Float) = 0
	}
	SubShader
	{
		Tags { "RenderType"="Opaque" }
		LOD 100 Cull Off

		Pass
		{
			CGPROGRAM
			#pragma vertex vert
      //追記
      #pragma multi_compile_instancing
			#pragma fragment frag
			#pragma multi_compile ___ ANIM_LOOP

			#include "UnityCG.cginc"

			#define ts _PosTex_TexelSize

			struct appdata
			{
				float2 uv : TEXCOORD0;
        //追記
        UNITY_VERTEX_INPUT_INSTANCE_ID
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				float3 normal : TEXCOORD1;
				float4 vertex : SV_POSITION;
				UNITY_VERTEX_INPUT_INSTANCE_ID
			};

			sampler2D _MainTex, _PosTex, _NmlTex;
			float4 _PosTex_TexelSize;
			float _Length;
			
    	//追記
			UNITY_INSTANCING_BUFFER_START(Props)
                UNITY_DEFINE_INSTANCED_PROP(float, _DT)
            UNITY_INSTANCING_BUFFER_END(Props)
     	//ここまで
			
			v2f vert (appdata v, uint vid : SV_VertexID)
			{
        //追記
			    UNITY_SETUP_INSTANCE_ID(v);
          
				//書き換え
				float t = (_Time.y - UNITY_ACCESS_INSTANCED_PROP(Props, _DT)) / _Length;
#if ANIM_LOOP
				t = fmod(t, 1.0);
#else
				t = saturate(t);
#endif
				float x = (vid + 0.5) * ts.x;
				float y = t;
				float4 pos = tex2Dlod(_PosTex, float4(x, y, 0, 0));
				float3 normal = tex2Dlod(_NmlTex, float4(x, y, 0, 0));
                
				v2f o;
				o.vertex = UnityObjectToClipPos(pos);
				o.normal = UnityObjectToWorldNormal(normal);
				o.uv = v.uv;
				
				return o;
			}
			
			half4 frag (v2f i) : SV_Target
			{
				half diff = dot(i.normal, float3(0,1,0))*0.5 + 0.5;
				half4 col = tex2D(_MainTex, i.uv);
				return diff * col;
			}
			ENDCG
		}
	}
}

下記を追記することでMaterialにEnable GPU Instancingの設定が追加されます。

#pragma multi_compile_instancing

このまま有効にすると、各オブジェクト毎のモデル行列やdelta timeが無視させてしまうのでデータを渡せるようにします。

構造体にUNITY_VERTEX_INPUT_INSTANCE_IDを追記することでインスタンスIDを利用して必要なモデル行列が取得できるようになります。

	struct appdata
	{
	  float2 uv : TEXCOORD0;
          //追記
          UNITY_VERTEX_INPUT_INSTANCE_ID
	};

頂点Shaderでインスタンスからデータを取り出してUnityObjectToClipPos側でよしなにモデル行列が反映されるようになります。

v2f vert (appdata v, uint vid : SV_VertexID)
			{
        		//追記
			    UNITY_SETUP_INSTANCE_ID(v);
          
                 (中略)
          
				v2f o;
				o.vertex = UnityObjectToClipPos(pos);
				o.normal = UnityObjectToWorldNormal(normal);
				o.uv = v.uv;
				
				return o;
			}

続いてC#側からdelta timeに乱数を渡せるようにします。下記でPropsを作成してfloatを一つ送り込めるようにします。

        //追記
      UNITY_INSTANCING_BUFFER_START(Props)
                UNITY_DEFINE_INSTANCED_PROP(float, _DT)
            UNITY_INSTANCING_BUFFER_END(Props)
        //ここまで
      

後はUNITY_ACCESS_INSTANCED_PROP(Props, _DT)で値を取り出して反映させます。

      v2f vert (appdata v, uint vid : SV_VertexID)
      {
            //追記
          UNITY_SETUP_INSTANCE_ID(v);
          
        //書き換え
        float t = (_Time.y - UNITY_ACCESS_INSTANCED_PROP(Props, _DT)) / _Length;
        
        (後略)

C#側からデータを渡す際はMaterialPropertyBlockを作成して、delta timeの値を格納しSetPropertyBlockで値を渡します。

        /// <summary>
       /// ShaderのプロパティID
       /// </summary>
       private readonly int _propertyId = Shader.PropertyToID("_DT");
       
       private void Start()

       MaterialPropertyBlock props = new MaterialPropertyBlock();
       props.SetFloat(_propertyId, Random.Range(0, 2f));
           
       fishObj.GetComponentInChildren<MeshRenderer>().SetPropertyBlock(props);
 }

これで魚一種につき1ドローコールで描画できるようになりました。

LookingGlassの解像度

ここまで書いといてあれなのだが、すまない、これで30fps出ていない。うん。

というわけで最終手段です。

Looking GlassにはQuilt Width&Height(画面出力の解像度)やNum Views(画面出力の枚数)を設定できます。

Num Viewsを減らした方が負荷が下がるのですが、そうするとLookingGlassの持ち味の立体感が薄まってしまいます。なので今回は解像度を4K(4096)から2K(2048)に落としました。

clipboard.png

以上でようやくカクツキが感じられない映像が出せました。

まとめ

受付システムとアクアリウムは1つのアプリに収まっていますが、実際は2つのアプリのようなものです。

そういった環境ゆえ、アクアリウム単体だけならQuilt Width&Heightが4Kでも問題無くても、受付システムも同時に動いているので処理負荷が高くて、フレーム落ちしてしまう。

アレコレと試行錯誤して、最終的には解像度を落とす苦渋の決断でなんとか完成させることができました。

まだ改善の余地はありそうですが、現時点でやれることはやったので今回はこの辺で終わりたいと思います。

途中で引用したスライドにもあるようにScriptable Render PipelineでPipelineを自作することで更に負荷は下げられると思います。

受付システム本体がStandard PipelineなのでScriptable Render Pipelineを使うとなると、ちゃぶ台返しになりそうなので今回は断念しましたが、1から作り直す機会があれば挑戦してみたい所です!!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる