📷

UnityでAndroidのCameraX APIを利用して動画撮影する

2024/09/10に公開

キービジュアル

はじめに

今回は Android の CameraX API を用いて、Unity アプリにカメラ撮影機能を実装する方法について書きたいと思います。

これを実装した背景は、Google が主催する Gemini を利用したコンペに応募するためのアプリ開発に必要だったからです。

実際に動かしてみた動画は以下です。Unity アプリが起動し、画面にはカメラのプレビューが表示されていませんが、実際に動画撮影が出来ていることが分かります。

https://x.com/edo_m18/status/1820597679388525032

Android Studio を利用した通常の Android アプリでは実装はとても簡単なのですが、ライブラリ化しつつ Unity のライフサイクルなどに合わせて実装するのに少し手間取ったので備忘録も兼ねて書きたいと思います。

Android Studio プロジェクトの設定

まずは Android Studio で CameraX API を利用した Android アプリ(ライブラリ)を作成します。そのための設定について書きます。

AnroidManifest.xml の設定

カメラを利用するため、以下の設定を <manifest> タグの中に追加します。

カメラと録音のパーミッションを設定
<manifest ...>
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />

    <!-- 後略 -->
</manifest>

Gradle の設定

ライブラリ開発をするにあたり、まずは Android Studio での Gradle の設定を行います。今回は CameraX API を利用するため以下の依存関係を追加します。

Gradle 設定
dependencies {
    implementation(libs.androidx.constraintlayout)
    implementation(project(":CameraRecorder"))

    val camerax_version = "1.1.0-beta01"
    implementation("androidx.camera:camera-core:${camerax_version}")
    implementation("androidx.camera:camera-camera2:${camerax_version}")
    implementation("androidx.camera:camera-lifecycle:${camerax_version}")
    implementation("androidx.camera:camera-video:${camerax_version}")
    implementation("androidx.camera:camera-view:${camerax_version}")
    implementation("androidx.camera:camera-extensions:${camerax_version}")

    // ... 後略
}

gradle.properties の設定

CameraX を利用することを伝えるため、 gradle.properties に以下の設定を追加します。

gradle.properties
android.useAndroidX=true

AAR 化するためのモジュールを追加

新規作成した Android プロジェクトに、新規にモジュールを追加します。このモジュールを AAR 化し、Unity で利用します。

[File] > [New] > [New Module] から追加画面を表示し、 Android Library を選択して項目を設定します。

モジュール追加

開いた画面で Android Library を選択します。各項目はそれぞれ適切に設定してください。

モジュールの設定

プロジェクトにモジュールの依存関係を追加する

本体アプリ側から、追加したモジュールのコードを利用する場合は Project Structure... から依存を追加する必要があります。

モジュールの依存関係の追加

CameraX API を利用するライブラリの実装

まずは実装するファイルをモジュールに追加します。(ここでは CameraRecorder.kt を追加しています)

ファイルの追加

CameraRecorder クラスの実装

実装する CameraRecorder クラスは Activity を受け取り、LifecycleOwner インターフェースを実装します。Activity を受け取るのは Activity からでしか利用できないものがあるため、インスタンス化する際に受け取ります。

シグネチャは以下の通り。

CameraRecorder クラスのコンストラクタ
class CameraRecorder(
    private val activity: Activity,
    private val targetName: String,
    private val cameraFacing: Int = 0, // 0 = Front, 1 = Back
) : LifecycleOwner {
    override val lifecycle: Lifecycle
        get() = lifecycleRegistry
}

ActivityUnityPlayerActivity を受け取る想定です。また、カメラの起動・終了などのライフサイクルを LifecycleOwner のイベントで管理するため、このインターフェースを実装しています。
このインターフェースは Lifecycle を取得する lifecycle プロパティを要求します。

LifecycleOwner インターフェース
public val lifecycle: Lifecycle

ライフサイクルの管理

今回の実装では、ライフサイクルのタイミングを以下のようにしています。

  1. コンストラクタ実行時に ON_CREATE イベントを発行
  2. startCapture メソッドで ON_START イベントを発行
  3. stopCapture メソッドで ON_STOP イベントを発行

イベントの通知には以下のように handleLifecycleEvent メソッドを利用します。

ライフサイクルの管理
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)

カメラの起動

カメラの起動には ProcessCameraProvider を利用します。また、起動時には「Usecase」として任意の数のユースケースオブジェクトを渡すことができます。
これは、例えばプレビュー UI にカメラの映像を表示したり、あるいは ImageCapture などの、画像取得のためのユースケースなど複数あります。今回は動画撮影をするため VideoCapture クラスを利用します。

カメラの起動
private fun startCapture() {
    // メインスレッドに切り替えて処理を行う
    Handler(Looper.getMainLooper()).post {
        Log.d(TAG, "Start camera with target $targetName")

        // ライフサイクルの ON_START イベントを発行
        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)

        // ProcessCameraProvider のインスタンスを取得
        val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
        val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

        // Recorder オブジェクトを生成し、VideoCapture の output に設定
        val recorder = Recorder.Builder()
            .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
            .build()
        videoCapture = VideoCapture.withOutput(recorder)

        val cameraSelector = if (cameraFacing == 0) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA

        try {
            // Unbind use cases before rebinding
            cameraProvider.unbindAll()
            // Bind use cases to camera
            cameraProvider.bindToLifecycle(this, cameraSelector, videoCapture)
            // 起動と同時に動画撮影を開始する
            captureVideo()
        }
        catch (exc: Exception) {
            Log.e(TAG, "Use case binding failed.", exc)
        }
    }
}

cameraProviderbindToLifecycle メソッドでライフサイクルオーナーとして自身と、撮影に使うカメラの方向(Front / Back)およびユースケースを指定してカメラを起動します。

動画撮影の開始

動画撮影には Recorder クラスを利用します。なお、今回は MediaStoreOutputOptions を利用して動画を保存するためギャラリー内に保存されます。

カメラ起動時に videoCapture.withOutput(recorder)Recorder オブジェクトを指定しています。これは videoCapture.output プロパティで参照できます。
このプロパティを通して動画撮影の準備および開始を行います。

また、今回は音声も同時に録音するため、recorder.withAudioEnabled() を呼び出しています。

録画の開始は start メソッドを実行します。引数にはラムダ式を渡し、中では VideoRecordEvent を受け取ります。これは、撮影の各種イベントを受け取るコールバックです。

特に重要なのは VideoRecordEvent.Finalize イベントで、このイベント内でファイルの場所を取得して Unity に通知します。

動画撮影の開始
recording = videoCapture.output
    .prepareRecording(context, mediaStoreOutputOptions)
    .apply {
        if (PermissionChecker.checkSelfPermission(this@CameraRecorder.context,
                Manifest.permission.RECORD_AUDIO) == PermissionChecker.PERMISSION_GRANTED) {
            withAudioEnabled()
        }
    }
    .start(ContextCompat.getMainExecutor(context)) { recordEvent ->
        when (recordEvent) {
            is VideoRecordEvent.Start -> {
                startVideoCaptureEvent(Unit)
            }
            is VideoRecordEvent.Finalize -> {
                // NOTE: なぜか hasError が true になるが、値は正常に保持しているため、チェックしない
                // if (recordEvent.hasError())
                // {
                //     recording?.close()
                //     recording = null
                //     Log.e(TAG, "Video capture ends with error: ${recordEvent.error}")
                //
                //     finishVideoCaptureEvent("")
                // }
                // else {
                    val msg = "Video capture succeeded: ${recordEvent.outputResults.outputUri}"
                    Log.d(TAG, msg)

                    var cursor: Cursor? = null
                    try {
                        val proj = arrayOf(MediaStore.Video.Media.DATA)
                        cursor = context.contentResolver.query(recordEvent.outputResults.outputUri, proj, null, null, null)

                        if (cursor != null)
                        {
                            val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)
                            cursor.moveToFirst()
                            val path = cursor.getString(columnIndex)
                            // Toast.makeText(activity.baseContext, path, Toast.LENGTH_SHORT)
                            //     .show()
                            Log.d(TAG, "Saved video at [$path]")

                            notifyCapturedEventToUnity(path)
                        }
                    }
                    catch (exc: Exception) {
                        cursor?.close()
                    }
                    finally {
                        cursor?.close()
                    }
                // }
            }
        }
    }

ファイルの保存場所の取得

Finalize イベント内で取得できる recordEvent.outputResults.outputUri は動画ファイルの URI です。しかし、前述のように今回の保存先には MediaStore を利用して保存しているため、この URI は content://... のようなパスとなり実際の動画のファイルパスではありません。これを、Unity から利用できるように動画の実際のファイルパスを検索して返すようにしています。

具体的には以下の部分です。

ファイルの保存場所の取得
var cursor: Cursor? = null
try {
    val proj = arrayOf(MediaStore.Video.Media.DATA)
    cursor = context.contentResolver.query(recordEvent.outputResults.outputUri, proj, null, null, null)

    if (cursor != null)
    {
        val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)
        cursor.moveToFirst()
        val path = cursor.getString(columnIndex)
        Log.d(TAG, "Saved video at [$path]")

        notifyCapturedEventToUnity(path)
    }
}
catch (exc: Exception) {
    cursor?.close()
}
finally {
    cursor?.close()
}

まず最初に MediaStore.Video.Media.DATA からビデオディレクトリのファイルパスを取得し、context.contentResolver.query を使ってファイルパスを取得します。
全体の流れとしては、ビデオが保存されているパスの絶対パスを取得し、そこから contentResolver に対してクエリを投げて、対象の位置(カーソル)を取得します。
そして取得したカーソルから、対象動画の絶対パスを取得しています。

動画撮影の終了

最後に、動画撮影の終了処理です。動画撮影の終了もメインスレッドから行う必要があります。
ライフサイクルとして ON_STOP イベントを発行し、さらにカメラのバインドを解除します。
また Record オブジェクトの stop メソッドを呼び出します。

これを実行すると撮影が終了し、動画ファイルが保存されます。
動画保存が完了すると前述の VideoRecordEvent.Finalize イベントが発生します。

動画撮影の終了
private fun stopCapture() {
    Log.d(TAG, "CameraRecorder is stopping video capturing.")

    Handler(Looper.getMainLooper()).post {
        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
        
        recording?.stop()
        recording = null
        
        val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
        val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
        cameraProvider.unbindAll()
    }
}

大まかな流れは以上です。実装自体はそこまで複雑ではありません。次はこれを AAR (ライブラリ)としてビルドし、Unity で利用できる形にします。

Unity の機能を参照できるようにする

ライブラリ化にあたり、イベントの開始・修了や、保存が完了した動画のファイルパスなどを Unity 側に通知する必要があります。今回は Unity が提供してくれている UnityPlayer.UnitySendMessage() を利用して通知します。

Unity の classes.jar の追加

Unity の classes.jar ファイルをプロジェクトに追加します。該当のファイルは以下のような場所に、Unity のバージョンごとに保存されています。

上記の jar ファイルをモジュール内の libs ディレクトリにコピーします。(存在しない場合は作成してください)

classes.jar の追加

Gradle の設定

これを利用するため、Unity が提供してくれている jar ファイルを参照できるようにします。ここでひとつ注意点があります。Unity が提供している jar ファイルは classes.jar という名前で、これ自体は Unity がビルドしたアプリ内に存在するため、今回の AAR 化の際に、この jar ファイルを含めてしまうと重複してしまうため、コンパイル時だけ参照するように設定しておく必要があります。

モジュール側の build.gradle に以下の設定を追加します。

UnityPlayer.jar の参照設定
dependencies {
    compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
    // ... 後略
}

最後に、今回実装したコード全文を載せておきます。不明な点は以下を参照ください。

コード全文
CameraRecorder.kt
package tokyo.meson.camerarecorder

import android.Manifest
import android.app.Activity
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.MediaStoreOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import com.unity3d.player.UnityPlayer
import java.text.SimpleDateFormat
import java.util.Locale
import java.io.File
import java.io.FileOutputStream
import kotlin.concurrent.thread

class CameraRecorder(
    private val activity: Activity,
    private val targetName: String,
    private val cameraFacing: Int = 0, // 0 = Front, 1 = Back
) : LifecycleOwner {

    companion object {
        private const val TAG = "CameraRecorder"
        private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
    }
    
    private val context: Context = activity
    private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)

    private var imageCapture: ImageCapture? = null
    private var videoCapture: VideoCapture<Recorder>? = null
    private var recording: Recording? = null
    
    val startVideoCaptureEvent = CameraRecorderEvent<Unit>()
    val finishVideoCaptureEvent = CameraRecorderEvent<String>()
    val finishGetFrameEvent = CameraRecorderEvent<String>()

    override val lifecycle: Lifecycle
        get() = lifecycleRegistry
    
    val isRecording: Boolean
        get() = recording != null
    
    init {
        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
    }

    private fun startCapture() {
        Handler(Looper.getMainLooper()).post {
            Log.d(TAG, "Start camera with target $targetName")

            lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)

            // ProcessCameraProvider のインスタンスを取得
            val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

            val recorder = Recorder.Builder()
                .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
                .build()
            videoCapture = VideoCapture.withOutput(recorder)

            val cameraSelector = if (cameraFacing == 0) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA

            try {
                // Unbind use cases before rebinding
                cameraProvider.unbindAll()

                // Bind use cases to camera
                cameraProvider.bindToLifecycle(this, cameraSelector, videoCapture)

                captureVideo()
            }
            catch (exc: Exception) {
                Log.e(TAG, "Use case binding failed.", exc)
            }
        }
    }

    private fun stopCapture() {
        Log.d(TAG, "CameraRecorder is stopping video capturing.")

        Handler(Looper.getMainLooper()).post {
            lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
            
            recording?.stop()
            recording = null
            
            val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
            cameraProvider.unbindAll()
        }
    }

    private fun captureVideo() {
        Log.d(TAG, "CameraRecorder will start or stop video capturing.")
        
        val videoCapture = this.videoCapture ?: return

        val curRecording = recording
        if (curRecording != null) {
            stopCapture()
            return
        }

        Log.d(TAG, "CameraRecorder is starting video capturing.")

        // Create and start a new recording session
        val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
            .format(System.currentTimeMillis())

        val contentValues = ContentValues().apply {
            put(MediaStore.MediaColumns.DISPLAY_NAME, name)
            put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
                put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/CameraX-Video")
            }
        }

        val mediaStoreOutputOptions = MediaStoreOutputOptions
            .Builder(activity.contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
            .setContentValues(contentValues)
            .build()

        recording = videoCapture.output
            .prepareRecording(context, mediaStoreOutputOptions)
            .apply {
                if (PermissionChecker.checkSelfPermission(this@CameraRecorder.context,
                        Manifest.permission.RECORD_AUDIO) == PermissionChecker.PERMISSION_GRANTED) {
                    withAudioEnabled()
                }
            }
            .start(ContextCompat.getMainExecutor(context)) { recordEvent ->
                when (recordEvent) {
                    is VideoRecordEvent.Start -> {
                        startVideoCaptureEvent(Unit)
                    }
                    is VideoRecordEvent.Finalize -> {
                        // NOTE: なぜか hasError が true になるが、値は正常に保持しているため、チェックしない
//                        if (recordEvent.hasError())
//                        {
//                            recording?.close()
//                            recording = null
//                            Log.e(TAG, "Video capture ends with error: ${recordEvent.error}")
//
//                            finishVideoCaptureEvent("")
//                        }
//                        else {
                            val msg = "Video capture succeeded: ${recordEvent.outputResults.outputUri}"
                            Log.d(TAG, msg)

                            var cursor: Cursor? = null
                            try {
                                val proj = arrayOf(MediaStore.Video.Media.DATA)
                                cursor = context.contentResolver.query(recordEvent.outputResults.outputUri, proj, null, null, null)

                                if (cursor != null)
                                {
                                    val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)
                                    cursor.moveToFirst()
                                    val path = cursor.getString(columnIndex)
                                    // Toast.makeText(activity.baseContext, path, Toast.LENGTH_SHORT)
                                    //     .show()
                                    Log.d(TAG, "Saved video at [$path]")

                                    notifyCapturedEventToUnity(path)
                                }
                            }
                            catch (exc: Exception) {
                                cursor?.close()
                            }
                            finally {
                                cursor?.close()
                            }
//                        }
                    }
                }
            }
    }

    fun start() {
        startCapture()
    }

    fun stop() {
        stopCapture()
    }

    fun getFrameAtTime(filePath: String, millseconds: Int) {
        thread {
            val retriever = MediaMetadataRetriever().apply {
                setDataSource(filePath)
            }

            val timeUs: Long = millseconds.toLong() * 1000L
            val bitmap = retriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_NEXT_SYNC)

            bitmap ?: return@thread

            val filename = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
                .format(System.currentTimeMillis())
            val file = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "${filename}.png")
            val fileOutputStream = FileOutputStream(file)
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fileOutputStream)
            fileOutputStream.flush()

            notifyCreatedFrameEventToUnity(file.path)
        }
    }

    fun deleteFile(filePath: String) {
        val file = File(filePath)
        file.delete()
    }

    private fun notifyCapturedEventToUnity(filePath: String) {
        Log.d(TAG, "Will send the video file path to Unity. [$filePath]")

        finishVideoCaptureEvent(filePath)

        UnityPlayer.UnitySendMessage(targetName, "CapturedVideo", filePath)
    }

    private fun notifyCreatedFrameEventToUnity(filePath: String) {
        Log.d(TAG, "Will send the image file path to Unity. [$filePath]")

        finishGetFrameEvent(filePath)

        UnityPlayer.UnitySendMessage(targetName, "CreatedFrame", filePath)
    }
}

AAR 化する

上記の CameraRecorder クラスを AAR ライブラリとしてビルドします。

メニューの [Build] > [Make Module '<MODULE_NAME>'] から対象プロジェクトをビルドします。

ビルド

ビルドが正常に終了すると、build/outputs/aar ディレクトリに AAR ファイルが生成されます。

AAR ファイル

Unity 側の実装

ビルドした AAR ファイルを Unity プロジェクト内の Plugins フォルダにコピーします。また、CameraX API を利用するためカスタムの Manifest ファイルなどを追加し設定する必要があります。

Project settings

Plugins 設定

上記の設定により追加されたカスタムの設定ファイルに、以下の設定を適宜追加してください。

Unity プロジェクトの設定

AndroidManifest の修正

カメラのレコーディングを実行するため、以下のパーミッションを追加してください。

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
実際に追加した [AndroidManifest.xml] の例
<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.unity3d.player"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />

    <application>
        <activity android:name="com.unity3d.player.UnityPlayerActivity"
                    android:theme="@style/UnityThemeSelector">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <meta-data android:name="unityplayer.UnityActivity" android:value="true" />
        </activity>
    </application>
</manifest>

Main gradle template の追加

ライブラリ側で AndroidX のカメラ機能を利用するため以下の依存関係を追加してください。

def cameraVersion = "1.3.1"
implementation("androidx.camera:camera-lifecycle:$cameraVersion")
implementation("androidx.camera:camera-camera2:$cameraVersion")
implementation("androidx.camera:camera-view:$cameraVersion")
implementation("androidx.camera:camera-core:$cameraVersion")
実際に設定した [mainTemplate.gradle] の例
apply plugin: 'com.android.library'
**APPLY_PLUGINS**

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    def cameraVersion = "1.3.1"
    implementation("androidx.camera:camera-lifecycle:$cameraVersion")
    implementation("androidx.camera:camera-camera2:$cameraVersion")
    implementation("androidx.camera:camera-view:$cameraVersion")
    implementation("androidx.camera:camera-core:$cameraVersion")
**DEPS**}

android {
    ndkPath "**NDKPATH**"

    compileSdkVersion **APIVERSION**
    buildToolsVersion '**BUILDTOOLS**'

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_11
        targetCompatibility JavaVersion.VERSION_11
    }

    defaultConfig {
        minSdkVersion **MINSDKVERSION**
        targetSdkVersion **TARGETSDKVERSION**
        ndk {
            abiFilters **ABIFILTERS**
        }
        versionCode **VERSIONCODE**
        versionName '**VERSIONNAME**'
        consumerProguardFiles 'proguard-unity.txt'**USER_PROGUARD**
    }

    lintOptions {
        abortOnError false
    }

    aaptOptions {
        noCompress = **BUILTIN_NOCOMPRESS** + unityStreamingAssets.tokenize(', ')
        ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~"
    }**PACKAGING_OPTIONS**
}
**IL_CPP_BUILD_SETUP**
**SOURCE_BUILD_SETUP**
**EXTERNAL_SOURCES**

gradle properties の追加

上記の理由と同様に、gradleTemplate.properties を追加する必要があります。生成されたテンプレートに以下を追加してください。

android.useAndroidX=true
実際に設定した [gradleTemplate.properties] の例
org.gradle.jvmargs=-Xmx**JVM_HEAP_SIZE**M
org.gradle.parallel=true
unityStreamingAssets=**STREAMING_ASSETS**
android.useAndroidX=true
**ADDITIONAL_PROPERTIES**

Unity C# からの呼び出し

Unity C# からは AndroidJavaClassAndroidJavaObject を利用してアクセスします。

以下は、実装した CameraRecorder のインスタンスを取得するコードです。インスタンス化する際に UnityPlayerActivity を渡しています。

CameraRecorder インスタンスの作成

```cs:CamerRecorder インスタンスの作成
private void Initialize()
{
#if UNITY_ANDROID    
    AndroidJavaObject activity = new AndroidJavaClass("com.unity3d.player.UnityPlayer").GetStatic<AndroidJavaObject>("currentActivity");
    
    int cameraFacing = 0; // 0 = Front, 1 = Back
    // namespace は実装したご自身のものに置き換えてください
    _recorderObject = new AndroidJavaObject("tokyo.mesoncamerarecorder.CameraRecorder", activity, name, cameraFacing );
#endif
}

Unity から録画の開始・終了を呼び出す

インスタンスを無事作成できたら、あとは任意のタイミングで録画の開始・修了を呼び出します。

録画の開始・終了
// 録画の開始
_recorderObject.Call("start");

// 録画の停止
_recorderObject.Call("stop");

録画終了時のコールバックの受け取り

保存完了後は UnityPlayer.SendMessage を利用して Unity 側に通知します。コールバックを受け取るには、該当のメソッドを C# 側に実装しておきます。

録画のコールバックの受け取り
public void CapturedVideo(string filePath)
{
    Debug.Log(filePath);
}

以上で Unity 側の実装は完了です。

最後に

応募した Gemini コンペ向けのアプリでは定期的に動画撮影をして、その動画を AI に投げて日記を作成する、というような趣旨のアプリです。そのためにカメラ撮影機能が必要でした。
CameraX API を利用したカメラアプリの開発はとても簡単ですが、Unity を経由すると必要となる Activity が用意できなかったりと少しハードルが高かったです。

もしかしたら Android 開発に精通しているとそこまで大変じゃないかもしれませんが、Android エンジニアではないので少し苦戦しました。

Unity アプリでカメラを使うケースは稀かと思いますが、なにかの参考になれば幸いです。

エンジニア絶賛募集中!

MESONではUnityエンジニアを絶賛募集中です! XRのプロジェクトに関わってみたい! 開発したい! という方はぜひご応募ください!

MESONのメンバーページからご応募いただくか、TwitterのDMなどでご連絡ください。

書いた人

えど

比留間 和也(あだな:えど)

カヤック時代にWEBエンジニアとしてリーダーを務め、その後VRに出会いコロプラに転職。 コロプラでは仮想現実チームにてXRコンテンツ開発に携わる。 DAYDREAM向けゲーム「NYORO THE SNAKE & SEVEN ISLANDS」をリリース。その後、ARに惹かれてMESONに入社。 MESONではARエンジニアとして活躍中。
またプライベートでもAR/VRの開発をしており、インディー部門でTGSに出展など公私関わらずAR/VRコンテンツ制作に精を出す。プライベートな時間でも開発しているように、新しいことを学ぶことが趣味で、最近は英語を学んでいる。

GitHub / Twitter

MESON Works

MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。

MESON Works

Discussion