Google Cardboard アプリの作り方

前回は Cardboard の組み立てでしたが、今回は Google VR SDK を用いて画像や動画を表示するアプリの作り方について説明します。

# SDK
[こちら](https://developers.google.com/vr/cardboard/overview) のページに、Unity、Android、iOS のそれぞれに向けた SDK が用意されています。

## iOS
iOS は CocoaPods からインストールすることができるので、こんな感じで Podfile を作成し、インストールします。

```ruby:Podfile
# Uncomment this line to define a global platform for your project
# platform :ios, '8.0'
# Uncomment this line if you're using Swift
# use_frameworks!

target 'vr-sample' do
    pod 'GVRSDK'
end
```

## Android
事前に [NDK](https://developers.google.com/vr/android/ndk/get-started) をインストールしておく必要があります。
SDK の libraries ディテクトリーの中に .AAR 形式でライブラリが提供されていますので、必要なライブラリをプロジェクトに追加します。
![Add Dependency Modules](https://qiita-image-store.s3.amazonaws.com/0/134810/bfc66823-59bc-ba9d-8069-19c96d0df2fd.png)

# ファイル形式
JPEG や H.264|MPEG-4 AVC であれば、iOS も Android も問題なく表示、再生できます。動画は HLS 配信にも対応しています。
また、コンテンツの種類として、Mono と Stereo を指定するのですが、[RICOH THETA S](https://theta360.com/) や [Kodak SP360](http://kodakpixpro.com/AsiaOceania/jp/cameras/actioncam/sp3604k/) などで撮影したものを使用する場合は、Mono を指定します。

# 画面イメージ
画面に配置した際のイメージはこのようになります。

<img width="320" alt="VR_Sample_2.png" src="https://qiita-image-store.s3.amazonaws.com/0/134810/6fe3fac8-145b-14c6-5391-b5af855bd29a.png">


# 画像の表示

## iOS
`GVRPanoramaView` を利用します。
VR 表示処理など、必要な処理はライブラリ側が行ってくれますので、アプリ側は View を配置して画像を読み込ませるだけです。

```objc:VrImageViewController.m
#import "VrImageViewController.h"
#import <GVRSDK/GVRPanoramaView.h>

@interface VrImageViewController () <GVRWidgetViewDelegate> {
    
}

@end

@implementation VrImageViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    CGRect viewFrame = CGRectZero;
    viewFrame.origin.x = 8;
    viewFrame.origin.y = 128;
    viewFrame.size.width = self.view.frame.size.width - viewFrame.origin.x * 2;
    viewFrame.size.height = floor(viewFrame.size.width * self.view.frame.size.width / self.view.frame.size.height);

    GVRPanoramaView *panoramaView = [[GVRPanoramaView alloc] initWithFrame:viewFrame];
    panoramaView.delegate = self;
    // 全画面表示切り替えボタン
    panoramaView.enableFullscreenButton = YES;
    // VR表示切り替えボタン
    panoramaView.enableCardboardButton = YES;
    [self.view addSubview:panoramaView];

    // 表示する画像を取得する。
    NSString *imageFilePath = [[NSBundle mainBundle] pathForResource:@"image_sample" ofType:@"jpg"];
    UIImage *vrImage = [UIImage imageWithContentsOfFile:imageFilePath];
    // 取得した画像を読み込ませる。
    [panoramaView loadImage:vrImage ofType:kGVRPanoramaImageTypeMono];
}

#pragma mark - Google VR Widget View Delegate

- (void)widgetViewDidTap:(GVRWidgetView *)widgetView
{
    // View がタップされた時に呼ばれる。
    NSLog(@"%s", __PRETTY_FUNCTION__);
}

- (void)widgetView:(GVRWidgetView *)widgetView didChangeDisplayMode:(GVRWidgetDisplayMode)displayMode
{
    // 表示モードが切り替わった時に呼ばれる。
    NSString *displayModeName = nil;
    switch (displayMode) {
        case kGVRWidgetDisplayModeEmbedded:
            // 埋め込み表示
            displayModeName = @"Embedded";
            break;
        case kGVRWidgetDisplayModeFullscreen:
            // 全画面表示
            displayModeName = @"Fullscreen";
            break;
        case kGVRWidgetDisplayModeFullscreenVR:
            // VR表示
            displayModeName = @"FullscreenVR";
            break;
        default:
            displayModeName = @"Unknown";
            break;
    }
    NSLog(@"%s - %@", __PRETTY_FUNCTION__, displayModeName);
}

- (void)widgetView:(GVRWidgetView *)widgetView didLoadContent:(id)content
{
    // コンテンツの読み込みが完了した時に呼ばれる。
    NSLog(@"%s - %@", __PRETTY_FUNCTION__, content);
}

- (void)widgetView:(GVRWidgetView *)widgetView didFailToLoadContent:(id)content withErrorMessage:(NSString *)errorMessage
{
    // コンテンツの読み込みに失敗した時に呼ばれる。
    NSLog(@"%s\n%@", __PRETTY_FUNCTION__, errorMessage);
}

@end
```

## Android
`VrPanoramaView` を利用します。
Android も iOS と同様に View を配置して、画像を読み込ませます。

```xml:main_activity.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="jp.co.ienter.vrsample.MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        android:id="@+id/textView" />

    <com.google.vr.sdk.widgets.pano.VrPanoramaView
        android:layout_width="match_parent"
        android:layout_height="256dp"
        android:layout_below="@+id/textView"
        android:layout_alignParentStart="true"
        android:id="@+id/vr_image_view"
        android:layout_marginTop="16dp">

    </com.google.vr.sdk.widgets.pano.VrPanoramaView>
</RelativeLayout>
```

```java:MainActivity.java
public class MainActivity extends AppCompatActivity {

    private VrPanoramaView panoramaView;
    private ImageLoaderTask imageLoaderTask;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        panoramaView = (VrPanoramaView) findViewById(R.id.vr_image_view);
        panoramaView.setEventListener(new PanoramaEventListener());

        imageLoaderTask = new ImageLoaderTask();
        imageLoaderTask.execute();
    }

    private class ImageLoaderTask extends AsyncTask<Void, Void, Boolean> {
        private Bitmap panoramaImage;
        @Override
        protected Boolean doInBackground(Void... params) {
            AssetManager assetManager = getAssets();
            InputStream inputStream;
            try {
                inputStream = assetManager.open("image_sample.jpg");
            } catch (IOException e) {
                e.printStackTrace();
                return  false;
            }
            panoramaImage = BitmapFactory.decodeStream(inputStream);
            try {
                inputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
            return true;
        }

        @Override
        protected void onPostExecute(Boolean aBoolean) {
            super.onPostExecute(aBoolean);

            VrPanoramaView.Options options = new VrPanoramaView.Options();
            options.inputType = VrPanoramaView.Options.TYPE_MONO;
            panoramaView.loadImageFromBitmap(panoramaImage, options);
        }
    }

    private class PanoramaEventListener extends VrPanoramaEventListener {

        @Override
        public void onLoadSuccess() {
            // コンテンツの読み込みに成功。
            super.onLoadSuccess();
            Log.d("VR", "Content Load Success.");
        }

        @Override
        public void onLoadError(String errorMessage) {
            // コンテンツの読み込みに失敗。
            super.onLoadError(errorMessage);
            Log.d("VR", errorMessage);
        }

        @Override
        public void onClick() {
            // View がタップされた時に呼ばれる。
            super.onClick();
            Log.d("VR", "Click");
        }

        @Override
        public void onDisplayModeChanged(int newDisplayMode) {
            // 表示モードが切り替わった時に呼ばれる。
            super.onDisplayModeChanged(newDisplayMode);
            String displayModeName;
            switch (newDisplayMode) {
                case VrWidgetView.DisplayMode.EMBEDDED:
                    displayModeName = "EMBEDDED";
                    break;
                case VrWidgetView.DisplayMode.FULLSCREEN_MONO:
                    displayModeName = "FULLSCREEN_MONO";
                    break;
                case VrWidgetView.DisplayMode.FULLSCREEN_VR:
                    displayModeName = "FULLSCREEN_VR";
                    break;
                default:
                    displayModeName = "UNKNOWN";
                    break;
            }
            Log.d("VR", "Display Mode = " + displayModeName);
        }
    }
}
```

# 動画の表示

## iOS
`GVRVideoView` を利用します。
こちらも `GVRPanoramaView` と同じように配置、設定します。

```objc:VrVideoViewController.m
#import "VrVideoViewController.h"
#import <GVRVideoView.h>

@interface VrVideoViewController () <GVRWidgetViewDelegate> {
    
}

@end

@implementation VrVideoViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.

    CGRect viewFrame = CGRectZero;
    viewFrame.origin.x = 8;
    viewFrame.origin.y = 128;
    viewFrame.size.width = self.view.frame.size.width - viewFrame.origin.x * 2;
    viewFrame.size.height = floor(viewFrame.size.width * self.view.frame.size.width / self.view.frame.size.height);
    
    GVRVideoView *videoView = [[GVRVideoView alloc] initWithFrame:viewFrame];
    videoView.delegate = self;  // Delegate の内容は GVRPanoramaView とほぼ同じだが、再生位置が取得できるようになっている。
    videoView.enableFullscreenButton = YES;
    videoView.enableCardboardButton = YES;
    [self.view addSubview:videoView];

    // 再生する動画ファイルをURL形式で指定する。
    NSURL *videoUrl = [[NSBundle mainBundle] URLForResource:@"video_sample" withExtension:@"mp4"];
    // HLS 配信の場合はこのようにURLを直接指定する。
    // NSURL *videoUrl = [NSURL URLWithString:@"https://vr.cloudfront.net/sample.m3u8"];
    [videoView loadFromUrl:videoUrl ofType:kGVRVideoTypeMono];
}

#pragma mark - Google VR Video View Delegate

- (void)widgetViewDidTap:(GVRWidgetView *)widgetView
{
    // View がタップされた時に呼ばれる。
    NSLog(@"%s", __PRETTY_FUNCTION__);
}

- (void)widgetView:(GVRWidgetView *)widgetView didChangeDisplayMode:(GVRWidgetDisplayMode)displayMode
{
    // 表示モードが切り替わった時に呼ばれる。
    NSString *displayModeName = nil;
    switch (displayMode) {
        case kGVRWidgetDisplayModeEmbedded:
            // 埋め込み表示
            displayModeName = @"Embedded";
            break;
        case kGVRWidgetDisplayModeFullscreen:
            // 全画面表示
            displayModeName = @"Fullscreen";
            break;
        case kGVRWidgetDisplayModeFullscreenVR:
            // VR表示
            displayModeName = @"FullscreenVR";
            break;
        default:
            displayModeName = @"Unknown";
            break;
    }
    NSLog(@"%s - %@", __PRETTY_FUNCTION__, displayModeName);
}

- (void)widgetView:(GVRWidgetView *)widgetView didLoadContent:(id)content
{
    // コンテンツの読み込みが完了した時に呼ばれる。
    NSLog(@"%s - %@", __PRETTY_FUNCTION__, content);
}

- (void)widgetView:(GVRWidgetView *)widgetView didFailToLoadContent:(id)content withErrorMessage:(NSString *)errorMessage
{
    // コンテンツの読み込みに失敗した時に呼ばれる。
    NSLog(@"%s\n%@", __PRETTY_FUNCTION__, errorMessage);
}

- (void)videoView:(GVRVideoView *)videoView didUpdatePosition:(NSTimeInterval)position
{
    // 再生位置を取得
    NSLog(@"%s - %lf", __PRETTY_FUNCTION__, position);
}

@end
```

## Android
`VrVideoView` を利用します。
`VrPanoramaView` と違い、動画ファイルをそのまま指定することができます。

```xml:activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="jp.co.ienter.vrsample.MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        android:id="@+id/textView" />

    <com.google.vr.sdk.widgets.video.VrVideoView
        android:layout_width="match_parent"
        android:layout_height="256dp"
        android:layout_below="@+id/textView"
        android:layout_alignParentStart="true"
        android:layout_marginTop="16dp"
        android:id="@+id/vr_video_view">

    </com.google.vr.sdk.widgets.video.VrVideoView>
</RelativeLayout>
```

```java:MainActivity.java
public class MainActivity extends AppCompatActivity {

    private VrVideoView videoView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        videoView = (VrVideoView) findViewById(R.id.vr_video_view);
        videoView.setEventListener(new VideoEventListener());
        try {
            VrVideoView.Options videoOptions = new VrVideoView.Options();
            videoOptions.inputType = VrVideoView.Options.TYPE_MONO;

            // HLS 配信の場合は、inputFormat に FORMAT_HLS を指定する。
            videoOptions.inputFormat = VrVideoView.Options.FORMAT_HLS;
            Uri uri = Uri.parse("https://vr.cloudfront.net/sample.m3u8");
            videoView.loadVideo(uri, videoOptions);

            // HLS 配信以外は FORMAT_DEFAULT を指定する。
            videoOptions.inputFormat = VrVideoView.Options.FORMAT_DEFAULT;
            videoView.loadVideoFromAsset("video_sample.mp4", videoOptions);
        } catch (IOException e) {
            e.printStackTrace();
            Log.d("VR", "Video Load Error.");
        }
    }

    private class VideoEventListener extends VrVideoEventListener {
        @Override
        public void onLoadSuccess() {
            // コンテンツの読み込みに成功。
            super.onLoadSuccess();
            Log.d("VR", "Content Load Success.");
        }

        @Override
        public void onLoadError(String errorMessage) {
            // コンテンツの読み込みに失敗。
            super.onLoadError(errorMessage);
            Log.d("VR", errorMessage);
        }

        @Override
        public void onClick() {
            // View がタップされた時に呼ばれる。
            super.onClick();
            Log.d("VR", "Click");
        }

        @Override
        public void onDisplayModeChanged(int newDisplayMode) {
            // 表示モードが切り替わった時に呼ばれる。
            super.onDisplayModeChanged(newDisplayMode);
            String displayModeName;
            switch (newDisplayMode) {
                case VrWidgetView.DisplayMode.EMBEDDED:
                    displayModeName = "EMBEDDED";
                    break;
                case VrWidgetView.DisplayMode.FULLSCREEN_MONO:
                    displayModeName = "FULLSCREEN_MONO";
                    break;
                case VrWidgetView.DisplayMode.FULLSCREEN_VR:
                    displayModeName = "FULLSCREEN_VR";
                    break;
                default:
                    displayModeName = "UNKNOWN";
                    break;
            }
            Log.d("VR", "Display Mode = " + displayModeName);
        }

        @Override
        public void onNewFrame() {
            // 動画再生位置を取得
            super.onNewFrame();
            Log.d("VR", "Position : " + videoView.getCurrentPosition());
        }

        @Override
        public void onCompletion() {
            // 動画再生が完了
            super.onCompletion();
        }
    }
}
```

# まとめ
このように画像や動画を表示させるだけであれば、あまり考えることなく実現できます。
メモリの使用量などを考慮する必要はありますが、複数のコンテンツを並べて表示することも可能でしたので、サムネイルを一覧で表示させることもできそうです。
また、HLS 配信に対応しているので、環境を整えればライブ配信もできそうです。