ARCore + Sceneform を使い、地球の周りを回る月の AR アニメーション効果を実現

最近、Googleの開発者カンファレンスを見ました。
その中で Android XR 技術が紹介されていて、「自分でもデモを作って実装してみようかな」と思いました。

そこで、Android StudioとARCore、Sceneformを使って、月が地球の周りを回る簡単なデモを作ってみました。

### 目標

Composeコンポーネント、ARCore、Sceneformを使用して、月が地球の周りを回るシンプルなAR表現を実現すること。

実際の動作イメージ：
<div style="transform: scale(0.3); transform-origin: top left;">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3843295/18928f92-e36a-4ee9-9e89-36e72fec991f.gif" />
</div>


### 実装の流れ

#### 開発環境

`Android Studio Meerkat Feature Drop`、`Gradle 8.11.1` を使用。

使用ライブラリ

```
[versions]
arCore = "1.43.0"
arsceneview = "2.3.0"

[libraries]
com-google-core-arcore = { group = "com.google.ar", name = "core", version.ref = "arCore" }

io-github-sceneview-arsceneview = { group = "io.github.sceneview", name = "arsceneview", version.ref = "arsceneview" }
```

#### 配置 Build.gradle.kts

```
 implementation(projects.core.designsystem)
 implementation(libs.androidx.appcompat)
 // ARCore & Sceneform
 implementation(libs.com.google.core.arcore)
 implementation(libs.io.github.sceneview.arsceneview)</pre>
```

#### 配置 AndroidManifest.xml

ここで注意が必要なのは、開発がモジュール分割された構成になっているため、`meta-data` に `tools:replace="android:value"` を設定する必要がある点です。

また、カメラを使用するため、カメラのパーミッション宣言も必要です。

```
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
 xmlns:tools="http://schemas.android.com/tools"
 package="com.iblogstreet.explore_ar">

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

 <uses-feature
 android:name="com.google.ar.core.depth"
 android:required="true" />
 <uses-feature
 android:name="android.hardware.camera.ar"
 android:required="true" />

 <application>
 <meta-data
 android:name="com.google.ar.core"
 android:value="required"
 tools:replace="android:value" />
 ...
 </application>

</manifest>
```

#### 代码实现　コード実現

`ExploreAREarthWithMoonScreen.kt`

ここではカメラの権限を動的にリクエストする必要があります。
 権限が許可されると、ページ（画面）を表示できるようになります。

```
@AndroidEntryPoint
class ExploreAREarthWithMoonScreen : AppCompatActivity() {

   override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Surface(
                modifier = Modifier.fillMaxSize(),
                color = MaterialTheme.colorScheme.background
            ) {
                val context = LocalContext.current
                val (hasPermission, requestPermission) = rememberCameraPermissionState(context)

                LaunchedEffect(Unit) {
                    if (!hasPermission) requestPermission()
                }
                if (hasPermission) {
                    EarthWithMoonScene()
                } else {
                    AlertDialog(
                        onDismissRequest = { },
                        title = { Text("Camera Permission Required") },
                        text = { Text("This app requires camera permission to function properly.") },
                        confirmButton = {
                            TextButton(onClick = requestPermission) {
                                Text("Grant Permission")
                            }
                        }
                    )
                }
            }
        }
    }

}
```

`EarthWithMoonSample.kt`

ここでは、アンカーと具体的な物体ノードを作成します。

```
val anchorNode = remember { AnchorNode(engine, anchor) }

   val earthNode = remember {
        ModelNode(
            modelInstance = modelLoader.createModelInstance("models/feature_explore_ar_earth.glb"),
            scaleToUnits = 0.1f
        )
    }

    //The moon is about 1/4 the size of the Earth
    val moonNode = remember {
        ModelNode(
            modelInstance = modelLoader.createModelInstance("models/feature_explore_ar_moon.glb"),
            scaleToUnits = 0.027f
        ).apply {
            position = Position(x = 0.3f, y = 0f, z = 0f)
        }
    }

    anchorNode.addChildNode(earthNode)
    anchorNode.addChildNode(moonNode)

    val earthRotation = remember { Animatable(0f) }
    val moonOrbitRotation = remember { Animatable(0f) }
    val moonSelfRotation = remember { Animatable(0f) }

    LaunchedEffect(Unit) {
        launch {
            try {
                while (isActive) {
                    earthRotation.snapTo((earthRotation.value + 1f) % 360f)
                    delay(16L)
                }
            } catch (e: Exception) {
                Log.e("EarthRotation", "Error during earth rotation", e)
            }
        }
        launch {
            try {
                while (isActive) {
                    moonOrbitRotation.snapTo((moonOrbitRotation.value + 1.5f) % 360f)
                    delay(16L)
                }
            } catch (e: Exception) {
                Log.e("MoonOrbitRotation", "Error during moon orbit", e)
            }
        }
        launch {
            try {
                while (isActive) {
                    moonSelfRotation.snapTo((moonSelfRotation.value + 1f) % 360f)
                    delay(16L)
                }
            } catch (e: Exception) {
                Log.e("MoonSelfRotation", "Error during moon self rotation", e)
            }
        }
    }

    LaunchedEffect(earthRotation.value, moonOrbitRotation.value, moonSelfRotation.value) {
        earthNode.rotation = Rotation(y = -earthRotation.value)

        val radians = Math.toRadians(moonOrbitRotation.value.toDouble())
        val x = 0.3f * kotlin.math.cos(radians).toFloat()
        val z = 0.3f * kotlin.math.sin(radians).toFloat()
        moonNode.position = Position(x = x, y = 0f, z = z)

        moonNode.rotation = Rotation(y = moonSelfRotation.value)
    }
```

#### 実装中に直面した問題

##### 問題説明

もし、`rememberOnGestureListener`の中で`childNodes` に`EarthWithMoonSample`を直接に追加しようとすると、`@Composable` 関数は `@Composable` な関数のコンテキスト内でしか呼び出せないため、 `@Composable invocations can only happen from the context of a @Composable function` というエラーが発生します。

```
onGestureListener = rememberOnGestureListener(
                onSingleTapConfirmed = { motionEvent, _ ->
                    if (childNodes.isNotEmpty()) {
                        return@rememberOnGestureListener
                    }
                    // Create an anchor at the tapped position
                    val hitResult = frame?.hitTest(motionEvent.x, motionEvent.y)?.firstOrNull()
                    if (hitResult != null && hitResult.isValid()) {
                        planeRenderer = false
                        val anchor = hitResult.createAnchorOrNull()
                        if (anchor != null) {
                            
                            childNodes.add(EarthWithMoonSample( engine = engine,
                                modelLoader = modelLoader,
                                anchor = anchor,
                                materialLoader = materialLoader))
                           
                        }
                    }
                }

            )
```

##### 解決方法

ノードを直接追加するのではなく、アンカーを Compose の状態として保持します。状態が変化すると、Compose が自動的に再コンポーズを行い、ノードが作成されます。

```
var anchorInstance = remember { mutableStateOf<Anchor?>(null) }

anchorInstance.value?.let {
    childNodes += EarthWithMoonSample(
        engine = engine,
        modelLoader = modelLoader,
        anchor = it,
        materialLoader = materialLoader
    )
}
...
val hitResult = frame?.hitTest(motionEvent.x, motionEvent.y)?.firstOrNull()
                    if (hitResult != null && hitResult.isValid()) {
                        planeRenderer = false
                        val anchor = hitResult.createAnchorOrNull()
                        if (anchor != null) {
                            anchorInstance.value = anchor
                        }
                    }
```

これで実装は完了です。

#### 公式のおすすめ

スマートフォン向けの AR を開発する場合、Sceneform はすでに非推奨となっているため、 Jetpack XR と [Filament](https://github.com/google/filament)の併用が推奨されています。

#### ソースコードへのリンク

[explore_ar](https://github.com/sayhellotogithub/advanceproject/tree/dev/feature/explore_ar)

#### AI

[ChatGPT](https://chatgpt.com/), [gemini](https://gemini.google.com/app),[claude.ai](https://claude.ai/new)

#### 資源

[sketchfab](https://sketchfab.com/feed)

[sceneview-android](https://github.com/SceneView/sceneview-android)

[Android XR](https://developer.android.com/xr?hl=ja)

[Filament](https://github.com/google/filament)