Xamarin.FormsでOpenGLを使って正距円筒図法の画像をグリグリ表示する

#はじめに
こんにちは。Daddy's Officeの市川です。

先日、「LiveCapture3 Remote」というスマホアプリのアップデートをリリースしました。
このアプリは、外出先からカメラのライブ映像を確認できるAndroid/iOS向けのアプリです。
今回のアップデートでは、リコーのThetaなどの360度全天球VRカメラのライブ視聴に対応しました。

今回は「LiveCapture3 Remote」の開発で行った、Xamarin.FormsでOpenGL ESを使用して、ThetaなどのVRカメラの出力フォーマットである「正距円筒図法」の画像を表示する方法を説明したいと思います。

#正距円筒図法とは

「正距円筒図法」とは、360度の空間映像を平面の映像として表現する方法です。
最もなじみの深いのが、世界地図ですね。
世界地図は、丸い地球を平面に投影することで、平面上で360度の世界を表現しています。
![map.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/382663/d74dadca-cd35-18cb-fb71-a9d191994f23.jpeg)
ほとんどの全天球カメラは、これと同じような方法で、360度空間の映像を出力します。
また、Youtubeなどの配信プラットフォームでも標準的な入力フォーマットとして採用されており、ほぼ360度全天球映像の標準フォーマット、という感じになっています。

実際にプログラムを組んで、この正距円筒図法の画像を表示する場合は、OpenGL等を用いて3Dプログラミングを行う必要があります。

OpenGL等で作成した3D空間上の球体オブジェクトの内側に、この正距円筒図法の映像を張り付け、視点を球体の中心に持ってくることで、360度すべての角度の映像を出力することができるようになるのです。

![無題.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/382663/fc944bad-a62a-a10c-ba53-95b95caddd6c.jpeg)

上図を見てもらえればわかると思いますが、出力映像は、全体の一部分のみになります。
そのため、正距円筒図法として4K(3840 x 2160など）の画サイズがあったとしても、実際に表示できる画サイズはその数分の一のサイズになってしまい、画質が落ちることになります。


ちなみに、ほとんどの全天球カメラは、前後に２つのレンズが付いており、その撮像を２つのイメージャ経由で取り込みます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/382663/29ec5e83-b4fd-4c75-040f-e2beada2d192.png)
この映像を「Dual-Fisheye」と呼びます。

一見すると、このまま3D球体に張り付ければよい気もしますが、この状態の映像は、そのままではカメラ特有のレンズのゆがみや、各イメージャ撮像間の重なりなどが存在します。
そのため、このまま3D球体に張り付けても歪みがあったり、２つの結合面（スティッチ境界）が目立ってしまって、正しい360度映像になりません。

その為、Dual-Fisheye映像を、そのまま使用する為には、各レンズ毎に異なるレンズの歪みの情報が必要になります。
こうしたレンズ情報を公開しているメーカもありますが、各レンズ毎に個別のシェーダを用意する必要があり、非常に大変です。
そのため、そうしたレンズ毎の差異をなくし、3D球体に張り付けたときに、歪みなく表示できる「正距円筒図法」が標準フォーマットとして採用されているのです。

![1214820_s.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/382663/a5186b0f-f565-e3f9-33f5-80e4d19f0960.jpeg)

#Xamarin.FormsでのOpenGLの使用方法

この「正距円筒図法」の画像を表示する為には、OpenGLを使用して3D空間を作る必要があります。

Xamarin(C#)でOpenGLといえば、[OpenTK][1]ほぼ一択ですが、Xmarin.Formsでのインプリメントに大変手こずりました。。。

Xamarin.Formsで使用したいので、まずは共通プロジェクトの.NetStandardでOpenTKを使おうと思いましたが、これがうまくいかず。。
一応、[OpenGLView][2]というクラスがあるのですが、どうにもうまく動きません。
Nugetで、.NetStandard用のOpenTKライブラリがいくつかあるのですが、それらを使おうとしても、なかなかうまくいかず。。
具体的には、Androidでは動くけどiOSでは動かず、とか、またはその逆とか。

調べてみると、どうやらAndroid用のOpenTKと、iOS用のOpenTKのバージョン不一致が起こっている模様でした。
ただ、Androidの方は、外部参照でOpenTKを単体で指定できるのですが、iOSの方は、Xamarin.iOSの中に組み込まれており、変更することはできなそうです。

![Xmarin.iOS.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/382663/f58a7e66-1fab-3675-ab97-886892c8ceeb.jpeg)

それでは、と、namespaceがOpenTKとは別の物に変更されたライブラリを使ってみたのですが、それでもうまくいかず。。。

結局、.NetStandardですべて記述することをあきらめて、カスタムレンダラー経由で、Android/iOS別々にコードを記述する方向にしました。
ただ、Nativeコントロールのコードは、どちらもOpenGLのコードということで、ほとんど同じになる為、SharedProjectで共通部分のクラスを書く、という方式で対応しました。

図に書くとこんな感じです。

![構成.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/382663/96fa33b2-3896-49cf-b098-59b92ec8347d.jpeg)

ここで問題になるのが、共通部分のNativeコントロールのベースクラスとして何を使うか、というところです。
ベタにAndroid/iOSのOpenGL用描画コードを書いても動くのですが、SheradProjectでなるべくコードの共通化を図りたいので、今回はOpenTKの[AndroidGameView][5]/[iPhoneOSGameView][6]を使いました。

このクラスを使うことで、OpenGLの初期化処理が簡単になり、ほぼ共通のコードでAndroid/iOSの両方のコントロールを記述することができます。

あと、VisualStudio2019では、共有プロジェクトの作成オプションがなくなってしまっています。。。
どこを探しても見つからなかったので、VisualStudio2017で共有プロジェクトを作成し、それをVisualStudio2019のプロジェクトに追加する方法で対応しました。

#サンプルコード説明
[こちらのGitHub][4]にサンプルソースコード一式を置いてあります。

主要クラスの概要は以下です。

|プロジェクト|クラス|説明|
|:----------|:-----|:---|
|EquirectangularSample|Controls.EquirectangularView|Xamarin.Forms上の正距円筒画像表示コントロール|
||Models.Sphere|球体オブジェクトクラス|
|EquirectangularSample.Android|Controls.EquirectanglarViewRenderer|Androidのカスタムレンダラー|
|EquirectangularSample.iOS|Controls.EquirectanglarViewRenderer|iOSのカスタムレンダラー|
|Shared|OGLView|共有プロジェクトのOpenGL描画用ネイティブコントロールクラス|

以下でコードの概要を説明しますが、OpenGLに関する説明は割愛しますので、参考に上げているリンク等を参照してください。

#球体オブジェクト(Sphere)

3D球体オブジェクトは、[このページのC++コード][3]を参考にして、C#で実装したクラスを、Xamarin.FormsのModelsに入れています。

#シェーダー

今回はOpenGL ES3.0を使いましたので、VertexShaderとFragmentShaderもES3.0の書式で記述しています。
ES2.0とは構文が大きく変わってしまっているので注意してください。

基本的には、球体オブジェクトで生成した頂点情報に正距円筒図法のテクスチャを張り付けるだけのもので、Controls.EquirectangularViewクラスの文字列メンバ変数に直接ハードコードで記述しています。

```C#:VertexShader/FragmentShader
            this.VertexShader =
                "#version 300 es                                  \n" +
                "in vec4 position;                                \n" +
                "in vec2 texcoord;                                \n" +
                "out vec2 textureCoordinate;                      \n" +
                "uniform mat4 projection;                         \n" +
                "void main()                                      \n" +
                "{                                                \n" +
                "    gl_Position = projection * position;         \n" +
                "    textureCoordinate = texcoord;                \n" +
                "}                                                \n";

            this.FragmentShader =
                "#version 300 es                                  \n" +
                "in highp vec2 textureCoordinate;                 \n" +
                "out lowp vec4 fragColor;                         \n" +
                "uniform lowp sampler2D text;                     \n" +
                "void main()                                      \n" +
                "{                                                \n" +
                "    fragColor = texture(text, textureCoordinate);\n" +
                "}                                                \n";

```

#OpenGLView

処理のほとんどは、このOpenGLViewに記述しています。
Android/iOS共通で使われるので、#ifディレクティブを使用してOS固有のコードを切り替えます。

```C#
using OpenTK;
using OpenTK.Graphics;
using OpenTK.Graphics.ES30;

using EquirectangularSample;

#if __ANDROID__
	using Android.Content;
	using Android.Graphics;
	using Android.Util;
	using Android.Views;
	using OpenTK.Platform.Android;
#endif

#if __IOS__
	using CoreGraphics;
	using Foundation;
	using ObjCRuntime;
	using OpenGLES;
	using OpenTK.Platform.iPhoneOS;
	using UIKit;
#endif
```

基本的なOpenGLのコードは同じなので、ほぼそのままで共通クラスを記述できるのですが、いくつかポイントがあります。

一つ目は初期化のタイミングです。
AndroidGameViewの方は、コントロールが作成されたタイミングでOnLoadメソッドがコールされるので、そこでOpenGLの初期化処理を行えばよいのですが、iPhoneOSGameViewの方はなぜかOnLoadが呼ばれません。
その為、OpenGLの初期化を外部のCustomRendererからコールする形にしています。

また、テクスチャ画像の読み込み処理（JPEGバイナリをRGBAピクセルデータに変換する処理もAndroid/iOSで異なります。

```C#:テクスチャのロード
		private void LoadTexture(int tex_id, byte[] texture)
		{
			GL.BindTexture(TextureTarget.Texture2D, tex_id);

			GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)All.NearestMipmapLinear);
			GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest);
			GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
			GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);

			//
			// 画像のロード＆RGBA色空間への変換＆OpenGLへの適用は、デバイス依存の処理になる
#if __ANDROID__
			using (var ms = new MemoryStream(texture))
			{
				var b = BitmapFactory.DecodeStream(ms);
				//
				// GLUTの関数を使って、良しなに設定する
				Android.Opengl.GLUtils.TexImage2D((int)All.Texture2D, 0, b, 0);

				b.Recycle();
			}
#endif

#if __IOS__
			var b = UIImage.LoadFromData(NSData.FromArray(texture));
			CGImage cgimage = b.CGImage;

			using (var dataProvider = cgimage.DataProvider)
			using (var data = dataProvider.CopyData())
			{
				GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba,
								(int)cgimage.Width, (int)cgimage.Height, 0,
								PixelFormat.Rgba, PixelType.UnsignedByte,
								data.Bytes);
			}
#endif
			GL.GenerateMipmap(TextureTarget.Texture2D);
		}

```

あとはタッチイベントの処理も、Android/iOS個別にこのクラスで実装します。
水平回転で球体モデルを回転させ、垂直回転はビュー（カメラの向き）に反映させます。

```C#:タッチイベント
#if __ANDROID__
		public override bool OnTouchEvent(MotionEvent e)
		{
			base.OnTouchEvent(e);

			if (e.Action == MotionEventActions.Down)
			{
				prevx = e.GetX();
				prevy = e.GetY();
			}

			if (e.Action == MotionEventActions.Move)
			{
				float e_x = e.GetX();
				float e_y = e.GetY();

				float xdiff = (prevx - e_x);
				float ydiff = (prevy - e_y);

				_viewAngleX += 90.0f * xdiff / (float)Size.Width;
				_viewAngleY -= 90.0f * ydiff / (float)Size.Height;

				while (_viewAngleX >= 360) _viewAngleX -= 360;
				while (_viewAngleX < 0) _viewAngleX += 360;

				_viewAngleY = Math.Max(-80, _viewAngleY);
				_viewAngleY = Math.Min(80, _viewAngleY);

				prevx = e_x;
				prevy = e_y;

				SetupProjection();
				RenderSphere();
			}

			return true;
		}
#endif

#if __IOS__
		public override void TouchesBegan(NSSet touches, UIEvent e)
		{
			var touch = (UITouch)e.TouchesForView(this).AnyObject;
			CGPoint pt = touch.LocationInView(this);
			prevx = (float)pt.X;
			prevy = (float)pt.Y;
		}

		public override void TouchesMoved(NSSet touches, UIEvent e)
		{
			var touch = (UITouch)e.TouchesForView(this).AnyObject;

			CGPoint pt = touch.LocationInView(this);
			float e_x = (float)pt.X;
			float e_y = (float)pt.Y;

			float xdiff = (prevx - e_x);
			float ydiff = (prevy - e_y);

			_viewAngleX += 90.0f * xdiff / (float)Size.Width;
			_viewAngleY -= 90.0f * ydiff / (float)Size.Height;

			while (_viewAngleX >= 360) _viewAngleX -= 360;
			while (_viewAngleX < 0) _viewAngleX += 360;

			_viewAngleY = Math.Max(-80, _viewAngleY);
			_viewAngleY = Math.Min(80, _viewAngleY);

			prevx = e_x;
			prevy = e_y;

			SetupProjection();
			RenderSphere();
		}
#endif

```

#終わりに

正距円筒図法の画像をAndroid/iOSでグリグリ表示するアプリをXamarin.Formsで（何とか）作ることができました。
この方法は、VR用途だけでなく、OpenGL ESをXamarin.Formsで使用するときの一つのパターンとして適用できそうです。

#参考

[OpenTK Project][1]
[OpenGL Sphere][3]
[床井研究室(OpenGLの情報が大変わかりやすく詳しく書かれています)][7]



[1]:https://opentk.net/
[2]:https://docs.microsoft.com/ja-jp/dotnet/api/xamarin.forms.openglview?view=xamarin-forms
[3]:http://www.songho.ca/opengl/gl_sphere.html
[4]:https://github.com/daddyYukio/EquirectangularSample
[5]:http://jwplayer.baseplex.de/docs/opentk/html/class_open_t_k_1_1_platform_1_1_android_1_1_android_game_view.html
[6]:http://jwplayer.baseplex.de/docs/opentk/html/class_open_t_k_1_1_platform_1_1i_phone_o_s_1_1i_phone_o_s_game_view.html
[7]:http://marina.sys.wakayama-u.ac.jp/~tokoi/