最近、Googleの開発者カンファレンスを見ました。
その中で Android XR 技術が紹介されていて、「自分でもデモを作って実装してみようかな」と思いました。
そこで、Android StudioとARCore、Sceneformを使って、月が地球の周りを回る簡単なデモを作ってみました。
目標
Composeコンポーネント、ARCore、Sceneformを使用して、月が地球の周りを回るシンプルなAR表現を実現すること。
実際の動作イメージ:
実装の流れ
開発環境
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の併用が推奨されています。