見出し画像

Looking Glassでアクアリウムを作ってみた

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

今回はその後、LookingGlass でアクアリウムを作ってみた話をnoteにつづっていこうと思います。

画像1

アクアリウム開発のきっかけ

弊社の受付では、3Dのキャラがお客様へ応対を行うシステム「超受付さん」を開発・運用しています。
※ 画面は開発中のものです。

この「超受付さん」の横にLooking Glassを配置して何かできないかと思案してみました。

画像2

第一弾:社員確認アプリ

超受付さん」で社員を呼び出した際に社員の3Dモデルと名前を LookingGlass に表示してみました。

全社員3Dスキャンしたので、見る方向を変えるとしっかりと3Dで見る事ができます😎 サイバーチックなエフェクトも加えて、イイ感じに仕上がりました!

第二弾:社員転がし

第二弾は一発ネタです。
公式のLeapMotionのサンプルを改造して、球体を社員の3Dモデルに置き換えました。

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

第三弾:アクアリウム

実用性のあるものからネタまで Looking Glass で作ってきましたが、第三弾は熱帯魚の水槽的なアプリ(アクアリウム)を作ってみました

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

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

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

閑話休題……🍵

ということで、本記事の主題「Looking Glassでアクアリウムを作ってみた」がここから始まります。

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

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

画像3

アクアリウム作成① 2画面出力設定

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

今回の機能は、50インチフルHDモニタを縦に配置したメイン画面(超受付さんのディスプレイ)と、横に置かれた 4K の Looking Glass というデュアルモニタ構成になっています。

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

画像4

Unity は複数モニタ出力に対応しているので、その機能をうまく利用しました。
Game ビューには左上にモニタ切り替えタブがあります。

画像5

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

画像6

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

すごい簡単! Unityさんありがとうございます!!

画像7

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 も実装できるんですね……すごい……。

画像8

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

画像9

〜〜 取材終わり 〜〜

Asset の準備
取材の結果、背景に欲しい要素は出揃いました!
・岩
・水草
・苔
・気泡
・枯れ木
これだけあれば良い感じの水槽になるでしょう!

岩と苔、枯れ木
配置する物の中で一番大物な岩を買いに AssetStore へ向かいます。

幸いな事に AssetStore では良い感じの岩がたくさん無料で出されています。 良い感じの岩をダウンロードして配置していきます。

画像10

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

草のように1本1本配置するには負荷が高すぎますし、配置する手間も掛かってしまいます。岩の形状を見て苔が生えそうなところに、自動でいい感じに苔を生やすの方法が楽ですよね〜。

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

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

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

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

画像11

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

画像12

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

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

画像13

さて、ダウンロードを……ん?

画像14

ん!?

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

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

というわけで方向性変更です!
ここまでシーンに設置した岩と苔はぶん投げます、捨てます。

Book Of The Dead: Environment から良さげな素材をおいしく頂きます。
良い感じの岩と枯れ木をピックアップしてシーンに配置したものがこちら。

画像15

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

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

水草
水草……水草が欲しい……が、とりあえず地上の草を生やしてもそれっぽく見えないかな?
…ということで、Book Of The Dead: Environment からテキトーに見繕った草をシーンに配置したものがこちらです。

画像16

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

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

アセットの Underwater FX を使用していきます。このアセットに気泡のShurikenパーティクルが含まれてるのでこれを利用します。草の辺りに仕込んで気泡作成完了です。

画像17

いい感じです👍

Fog(ぼかし)
Looking Glassでは綺麗な立体に見えるスイートスポットが狭いので、奥にオブジェクトがあるとあまり綺麗に見えません。
そこで、Fog を焚いてぼかします。これだけで奥が綺麗に見えるようになりました。

画像18

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

画像19

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

……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つにしてくれるアセットです。​最近は VRchat などでドローコールがより意識されてるのでそちら方面でもよく使われているようですね。

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

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

画像20

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

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

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

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

画像21

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

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

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

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

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

画像22

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

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

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

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

AssetStore にありました!

Swarm Agent を買いました。

画像23

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

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

画像24

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

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

画像25

魚の軽量化
魚を試しに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

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

画像26

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

このまま大量生成してしまうと全く同じ再生タイミングになってしまうので、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 を分けてライトの影響
を受けないようにします。

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

画像27

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) に落としました。

画像28

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

まとめ

超受付さん」と「アクアリウム」は1つのアプリに収まっていますが、実際は2つのアプリのようなものです。

そういった環境ゆえ、アクアリウム単体だけなら Quilt Width&Height が 4K でも問題無くても、受付システムも同時に動いているので処理負荷が高くて、フレーム落ちしてしまう。
アレコレと試行錯誤して、最終的には解像度を落とす苦渋の決断でなんとか完成させることができました。

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

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

超受付さん」が Standard Pipeline なので Scriptable Render Pipeline を使うとなると、ちゃぶ台返しになりそうなので今回は断念しましたが、1から作り直す機会があれば挑戦してみたいところです!!


この記事が気に入ったらサポートをしてみませんか?