Android XR アプリ開発 | Scene Viewer による3Dモデル表示の構築  | 技術ブログ | 株式会社OnePlanet 読み込まれました

2024/12/24

Android XR アプリ開発 | Scene Viewer による3Dモデル表示の構築

はじめに

今回は、Scene Viewer の機能を使って3Dモデルを表示するAndroid XRアプリのサンプルアプリを作成します。

(この記事は、Android XR Advent Calendar 2024 の25日目です。)

事前準備

Android StudioによるAndroid XRアプリ開発の環境構築を事前に実施しておいてください。

https://1planet.co.jp/tech-blog/android-xr-oneplanet-241214-1-pre-start-develop

プロジェクト作成

XR : Basic Headset Activity

プロジェクトを作成するテンプレートはXRのBasic Headset Activityになります。選択後、Nextボタンをクリックします。

入力情報

Name、Package name、Save locationは上記以外の情報でも問題ありません。入力完了後、Finishボタンをクリックします。

プロジェクト作成完了後、Finishボタンをクリックします。

実装

MainActivity.ktのソースコードを修正します。

Scene Viewer の 処理

class MainActivity : ComponentActivity() {

    @SuppressLint("RestrictedApi")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        setContent {
            OnePlanetAndroidXRSceneViewerSampleTheme {
                val session = LocalSession.current
                if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
                    Subspace {
                        MySpatialContent(onRequestHomeSpaceMode = { session?.requestHomeSpaceMode() })
                    }
                } else {
                    My2DContent(onRequestFullSpaceMode = { session?.requestFullSpaceMode() })
                }
            }
        }
    }

    fun launchSceneViewer() {
        val THREED_MODEL_URL = "https://modelviewer.dev/shared-assets/models/RobotExpressive.glb"
        val MIME_TYPE = "model/gltf-binary"
        val sceneViewerIntent = Intent(Intent.ACTION_VIEW)
        val intentUri = Uri.parse("https://arvr.google.com/scene-viewer/1.2")
            .buildUpon()
            .appendQueryParameter("file", THREED_MODEL_URL)
            .build()
        sceneViewerIntent.setDataAndType(intentUri, MIME_TYPE)
        startActivity(sceneViewerIntent)
    }
}

MainActivity に launchSceneViewer というメソッドを追加します。launchSceneViewer内でGLBファイルの3Dモデルを読み込み、Scene Viewer のインテントを作成後、startActivityにセットします。(モデルは以下を使用しました。)

https://modelviewer.dev/examples/animation/index.html

Scene Viewer の呼び出しボタン

@Composable
fun SceneViewerButton() {
    val context = LocalContext.current
    Button(onClick = {
        (context as? MainActivity)?.launchSceneViewer()
    }) {
        Text(text = "3Dモデル表示")
    }
}

SceneViewerButtonのコードを追加します。

Scene Viewer の呼び出しボタンを配置

@Composable
fun MainContent(modifier: Modifier = Modifier) {
    Box(
        Modifier
            .background(color = Color.LightGray)
            .height(1280.dp)
            .width(800.dp),
        contentAlignment = Alignment.Center
    ) {
        SceneViewerButton()
    }
}

テンプレートが自動生成したMainContentの中身を変更します。先程、実装したSceneViewerButtonを配置します。

全ソースコード

package jp.co.oneplanet.androidxr.sample.oneplanetandroidxrsceneviewersample

import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.xr.compose.platform.LocalHasXrSpatialFeature
import androidx.xr.compose.platform.LocalSession
import androidx.xr.compose.platform.LocalSpatialCapabilities
import androidx.xr.compose.spatial.Subspace
import androidx.xr.compose.subspace.SpatialPanel
import androidx.xr.compose.subspace.layout.SubspaceModifier
import androidx.xr.compose.subspace.layout.height
import androidx.xr.compose.subspace.layout.movable
import androidx.xr.compose.subspace.layout.resizable
import androidx.xr.compose.subspace.layout.width
import jp.co.oneplanet.androidxr.sample.oneplanetandroidxrsceneviewersample.ui.theme.OnePlanetAndroidXRSceneViewerSampleTheme

class MainActivity : ComponentActivity() {

    @SuppressLint("RestrictedApi")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        setContent {
            OnePlanetAndroidXRSceneViewerSampleTheme {
                val session = LocalSession.current
                if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
                    Subspace {
                        MySpatialContent(onRequestHomeSpaceMode = { session?.requestHomeSpaceMode() })
                    }
                } else {
                    My2DContent(onRequestFullSpaceMode = { session?.requestFullSpaceMode() })
                }
            }
        }
    }

    fun launchSceneViewer() {
        val THREED_MODEL_URL = "https://modelviewer.dev/shared-assets/models/RobotExpressive.glb"
        val MIME_TYPE = "model/gltf-binary"
        val sceneViewerIntent = Intent(Intent.ACTION_VIEW)
        val intentUri = Uri.parse("https://arvr.google.com/scene-viewer/1.2")
            .buildUpon()
            .appendQueryParameter("file", THREED_MODEL_URL)
            .build()
        sceneViewerIntent.setDataAndType(intentUri, MIME_TYPE)
        startActivity(sceneViewerIntent)
    }
}

@SuppressLint("RestrictedApi")
@Composable
fun MySpatialContent(onRequestHomeSpaceMode: () -> Unit) {
    SpatialPanel(SubspaceModifier.width(1280.dp).height(800.dp).resizable().movable()) {
        Surface {
            MainContent(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(48.dp)
            )
        }
    }
}

@SuppressLint("RestrictedApi")
@Composable
fun My2DContent(onRequestFullSpaceMode: () -> Unit) {
    Surface {
        Row(
            modifier = Modifier.fillMaxSize(),
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            MainContent(modifier = Modifier.padding(48.dp))
            if (LocalHasXrSpatialFeature.current) {
                FullSpaceModeIconButton(
                    onClick = onRequestFullSpaceMode,
                    modifier = Modifier.padding(32.dp)
                )
            }
        }
    }
}

@Composable
fun MainContent(modifier: Modifier = Modifier) {
    Box(
        Modifier
            .background(color = Color.LightGray)
            .height(1280.dp)
            .width(800.dp),
        contentAlignment = Alignment.Center
    ) {
        SceneViewerButton()
    }
}

@Composable
fun SceneViewerButton() {
    val context = LocalContext.current
    Button(onClick = {
        (context as? MainActivity)?.launchSceneViewer()
    }) {
        Text(text = "3Dモデル表示")
    }
}

@Composable
fun FullSpaceModeIconButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
    IconButton(onClick = onClick, modifier = modifier) {
        Icon(
            painter = painterResource(id = R.drawable.ic_full_space_mode_switch),
            contentDescription = stringResource(R.string.switch_to_full_space_mode)
        )
    }
}

@Composable
fun HomeSpaceModeIconButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
    FilledTonalIconButton(onClick = onClick, modifier = modifier) {
        Icon(
            painter = painterResource(id = R.drawable.ic_home_space_mode_switch),
            contentDescription = stringResource(R.string.switch_to_home_space_mode)
        )
    }
}

@PreviewLightDark
@Composable
fun My2dContentPreview() {
    OnePlanetAndroidXRSceneViewerSampleTheme {
        My2DContent(onRequestFullSpaceMode = {})
    }
}

@Preview(showBackground = true)
@Composable
fun FullSpaceModeButtonPreview() {
    OnePlanetAndroidXRSceneViewerSampleTheme {
        FullSpaceModeIconButton(onClick = {})
    }
}

@PreviewLightDark
@Composable
fun HomeSpaceModeButtonPreview() {
    OnePlanetAndroidXRSceneViewerSampleTheme {
        HomeSpaceModeIconButton(onClick = {})
    }
}

ビルド

bundle.gradle.kts(Module :app)の修正

minSdk = 34 に修正します。

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "jp.co.oneplanet.androidxr.sample.oneplanetandroidxrsceneviewersample"
    compileSdk = 35

    defaultConfig {
        applicationId = "jp.co.oneplanet.androidxr.sample.oneplanetandroidxrsceneviewersample"
        minSdk = 34
        targetSdk = 35
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = "11"
    }
    buildFeatures {
        compose = true
    }
}

dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.lifecycle.runtime.compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.runtime)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.ui)
    implementation(libs.androidx.ui.graphics)
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    implementation(libs.androidx.compose)
    implementation(libs.runtime)
    implementation(libs.androidx.scenecore)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.ui.test.junit4)
    debugImplementation(libs.androidx.ui.tooling)
    debugImplementation(libs.androidx.ui.test.manifest)
}

Clean Project

Clean Projectを実行します。

Sync Project with Gradle Files

Sync Project with Gradle Filesを実行します。

実行

XR Deviceを選択した状態で▷(Run 'app')を選択するとAndroid XR エミュレーターが起動します。

3Dモデル表示ボタンをクリックするとScene Viewerに切り替わり、GLBファイルの3Dモデルが表示されます。

まとめ

今回は、Scene Viewerを利用して3Dモデルを表示するAndroid XRアプリのサンプルを作成する手順を解説しました。基本的なプロジェクト作成から、Scene Viewerを呼び出すボタンの実装まで、具体的なコード例を交えて解説しました。

Scene Viewerは、簡単なインテント呼び出しでGLB形式の3Dモデルを表示できる強力なツールです。今回のサンプルを参考に、より魅力的なXR体験を提供するアプリを開発してください。

作成したプロジェクト

作成したプロジェクトはgithubにあります。

https://github.com/tokufxug/OnePlanetAndroidXRSceneViewerSample

参考情報

Android XRとは|できること、デバイス、開発ツールなど解説

Android XR の情報について以下の記事をご参照ください。

https://ar-marketing.jp/android-xr-what-it-can-be-used-for-devices-tools/

Android XR 技術ブログ

https://1planet.co.jp/tech-blog/category/AndroidXR


空間コンピューティングやARアプリ開発に対する弊社の取り組み

空間コンピューティングやARアプリ開発の取り組みについては、こちらをご覧ください。

https://1planet.co.jp/work/consulting

お問い合わせ

AR関するご依頼・ご相談など、お気軽にお問い合わせください。

https://1planet.co.jp/contact

XR エンジニア

徳山 禎男

SIerとして金融や飲料系など様々な大規模プロジェクトに参画後、2020年にOnePlanetに入社。ARグラスを中心とした最先端のAR技術のR&Dや、法人顧客への技術提供を担当。過去にMagic Leap 公式アンバサダーを歴任。

View More

お問い合わせ・ご相談

ARでやってみたいことやお困りごとなど
お気軽にお問い合わせください。

お問い合わせ