0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-06-03

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

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

目標

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

実際の動作イメージ:

実装の流れ

開発環境

Android Studio Meerkat Feature DropGradle 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-datatools: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の中でchildNodesEarthWithMoonSampleを直接に追加しようとすると、@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の併用が推奨されています。

ソースコードへのリンク

explore_ar

AI

ChatGPT, gemini,claude.ai

資源

sketchfab

sceneview-android

Android XR

Filament

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?