🤖

ARのSNSを作ってみる オブジェクトの永続・共有編

2024/12/04に公開

初めに

以下の記事の続きになります。興味のある方は読んでみてください。

https://zenn.dev/taiseishimizu/articles/482ce039992e24

https://zenn.dev/taiseishimizu/articles/ea6b7f13069e50

オブジェクトの作成、描写、操作ができるようになったので、このアプリの肝であるオブジェクトの共有(ユーザが投稿したオブジェクトを他ユーザも閲覧可能にする)を進めていきます。
ARCoreはARセッションの開始とともに(自身の端末を原点とする)ワールド座標を設定し、IMUセンサーとカメラから検出した特徴点でワールド座標からどれくらい移動したかを計算し、端末とアンカーとの距離、向きを求めます。つまり、AR空間のアンカーは端末の初期位置によって決定されるため、素のARCoreだと異なる端末間でアンカーの共有はできません。
異なる端末間でアンカーを共有する方法は以下の2つがあります。

  • Cloud Anchors
  • Geospatial API

Cloud Anchorsは触ったことないので詳しくは分かりませんが、ホスト端末がアンカーと3D特徴マップをクラウドにアップし、ゲスト端末も3D特徴マップをクラウドにアップして、3D特徴マップを元にゲスト端末にもアンカーを作成するみたいな感じだと思います。どちらかと言えばAR卓球ゲームみたいなリアルタイムでAR空間を共有するのに使うイメージです。

https://developers.google.com/ar/develop/cloud-anchors?hl=ja

Geospatial APIはVisual Positioning Systemを利用してより正確な位置情報を取得することで、ワールド座標と現実世界の座標を照らし合わせ、アンカーの地理空間情報(経度、緯度、高度など)を取得したり、現実世界の特定の地点にオブジェクトを配置したり出来ます。端末を基準としてAR空間を形成していたのに対して、Geospatial APIは現実世界そのものをAR空間にしているようなイメージです。

https://developers.google.com/ar/develop/geospatial?hl=ja

今回はARのSNSを作るのが目的で、リアルタイムの共有に重きを置いていないにので、Geospatial APIを利用します。

オブジェクトの永続化

まずARCoreでGeospatial APIを有効化して下さい。

https://developers.google.com/ar/develop/java/geospatial/enable?hl=ja

有効化するとsessionからearthオブジェクトが取得でき、earthオブジェクトで地理空間情報にアクセスできます。
以下はアンカーの地理空間情報を生成しています。ちなみにアンカーはワールド座標基準の回転なのに対して、Geospatial APIは地球全体を基準とした回転になるので、アンカーの回転とGeospatial APIの回転の値は異なります。
今回の目的はARのSNSなので、取得できたアンカーの地理空間情報とユーザが入力した内容などをDBに永続化します。

class ArMessageRenderer(...) : ... {

    private var geospatialObjectCollection = CopyOnWriteArrayList<GeospatialObject>()

    private fun saveAnchor(anchor: WrappedAnchor) {
        val earth = session!!.earth
        if (earth?.trackingState != TrackingState.TRACKING) return
        val geospatialAnchor = earth.getGeospatialPose(anchor.anchor.pose)
        val longitude = geospatialAnchor!!.longitude
        val latitude = geospatialAnchor.latitude
        val altitude = geospatialAnchor.altitude
        val quaternion = geospatialAnchor.getEastUpSouthQuaternion()
        val geospatialData = renderObject.saveAnchor(longitude, latitude, altitude, quaternion)
        geospatialObjectCollection.add(GeospatialObject(geospatialData, renderObject.virtualObjectMesh, renderObject.virtualObjectShader))
    }
}

private data class WrappedAnchor(
    val anchor: Anchor,
    val trackable: Trackable,
)

private data class GeospatialObject(
    val geospatialData: GeospatialData, //経度、緯度、高度などの情報を持ったインターフェース
    val mesh: Mesh,
    val shader: Shader,
)

オブジェクトの共有

まずは自身の位置情報をearthオブジェクトから取得します。初回もしくは取得した位置情報が前回取得した位置より一定幅動いていたら、現在地の情報を元にDB検索をし、オブジェクトを取得します。記事にはしていないですが、絵文字の3Dオブジェクトも投稿できるようにしているので、メッセージと絵文字それぞれ取得します。オブジェクトの生成周りが結構異なったので、メッセージと絵文字でクラスは分けています。

class ArMessageRenderer(...) : ... {

    //ユーザの現在地
    private lateinit var userLastPosition: UserCurrentPosition
    //メッセージと絵文字のオブジェクト情報を持つコレクション
    private val geospatialArMessageCollection = GeospatialArMessageCollection()
    private var geospatialEmojiCollection     = GeospatialEmojiCollection()
    
    override fun onCreate(owner: LifecycleOwner) {
        val delayMillis: Long = 1000 * 3 // 3秒毎
        handler.postDelayed(object: Runnable {
            override fun run() {
                setGeospatialArMessageCollection()
                handler.postDelayed(this, delayMillis)
            }
        }, delayMillis)
    }
    
    private fun setGeospatialArMessageCollection() {
        val session = session ?: return
        val earth = session.earth
        if (earth!!.trackingState != TrackingState.TRACKING) return
        val geospatialPose = earth.cameraGeospatialPose
        if (::userLastPosition.isInitialized) {
            val userNowPosition = UserCurrentPosition(geospatialPose.longitude, geospatialPose.latitude, geospatialPose.altitude)
            if (userLastPosition.comparePosition(userNowPosition)) {
                geospatialArMessageCollection.cleanCollection()
                geospatialEmojiCollection.cleanCollection()
                GeospatialArMessageModel().getArMessage(userNowPosition, geospatialArMessageCollection)
                GeospatialEmojiModel().getEmoji(userNowPosition, geospatialEmojiCollection)
                createGeospatialObjct = true
            }
        } else {
            userLastPosition = UserCurrentPosition(geospatialPose.longitude, geospatialPose.latitude, geospatialPose.altitude)
            GeospatialArMessageModel().getArMessage(userLastPosition, geospatialArMessageCollection)
            GeospatialEmojiModel().getEmoji(userLastPosition, geospatialEmojiCollection)
            createGeospatialObjct = true
        }
    }
}

取得したオブジェクト情報を元に、オブジェクトの生成を行います。オブジェクトの生成はDB取得したタイミングでのみ行い、位置情報に動きがなければ、生成済みのオブジェクトを利用します。

class ArMessageRenderer(...) : ... {

   //地理空間情報を持つオブジェクトのコレクション
   private var geospatialObjectCollection = CopyOnWriteArrayList<GeospatialObject>()

   private fun createGeospatialArMessage(render: SampleRender) {
       if (geospatialArMessageCollection.geospatialArMessages.size > 0) {
           geospatialArMessageCollection.geospatialArMessages.forEach { geospatialArMessage ->
               //画像をもとにオブジェクト作成
               val geospatialObjectAlbedoTexture =
                   Texture.createFromBitmap(
                       render,
                       geospatialArMessage.messageTextBitmap.messageTextBitmap,
                       Texture.WrapMode.CLAMP_TO_EDGE,
                       Texture.ColorFormat.SRGB
                   )

               val geospatialObjectMesh = Mesh.createFromInternalStorage(render,  geospatialArMessage.messageTextObjectPath)
               val geospatialObjectShader =
                   Shader.createFromAssets(
                       render,
                       "shaders/environmental_hdr.vert",
                       "shaders/environmental_hdr.frag",
                       mapOf("NUMBER_OF_MIPMAP_LEVELS" to cubemapFilter.numberOfMipmapLevels.toString())
                   )
                       .setTexture("u_AlbedoTexture", geospatialObjectAlbedoTexture)
                       .setTexture("u_Cubemap", cubemapFilter.filteredCubemapTexture)
                       .setTexture("u_DfgTexture", dfgTexture)
               geospatialObjectCollection.add(GeospatialObject(geospatialArMessage.arMessageData, geospatialObjectMesh, geospatialObjectShader))
           }
       }
   }

   private fun createEmojiObject(render: SampleRender) {
       if (geospatialEmojiCollection.geospatialEmojis.size > 0) {
           geospatialEmojiCollection.geospatialEmojis.forEach { emojiData ->
               //絵文字の種類でオブジェクトを作成
               val geospatialObjectAlbedoTexture =
                   Texture.createFromAsset(
                       render,
                       "画像のパス"+ emojiData.emojiType + ".jpg",
                       Texture.WrapMode.CLAMP_TO_EDGE,
                       Texture.ColorFormat.SRGB
                   )

               val geospatialObjectMesh = Mesh.createFromAsset(render,  "モデルのパス" + emojiData.emojiType + ".obj")
               val geospatialObjectShader =
                   Shader.createFromAssets(
                       render,
                       "shaders/environmental_hdr.vert",
                       "shaders/environmental_hdr.frag",
                       mapOf("NUMBER_OF_MIPMAP_LEVELS" to cubemapFilter.numberOfMipmapLevels.toString())
                   )
                       .setTexture("u_AlbedoTexture", geospatialObjectAlbedoTexture)
                       .setTexture("u_Cubemap", cubemapFilter.filteredCubemapTexture)
                       .setTexture("u_DfgTexture", dfgTexture)
               geospatialObjectCollection.add(GeospatialObject(emojiData, geospatialObjectMesh, geospatialObjectShader))
           }
       }
   }
}

最後に生成されたオブジェクトの描写を行います。

class ArMessageRenderer(...) : ... {

    //地理空間情報を持つオブジェクトのコレクション
    private var geospatialObjectCollection = CopyOnWriteArrayList<GeospatialObject>()
    
    private fun renderGeospatialObject(render: SampleRender) {
        val earth = session?.earth
        if (earth!!.trackingState != TrackingState.TRACKING) return
        geospatialObjectCollection.forEach { geospatialObject ->
            val anchor = earth.createAnchor(
                geospatialObject.geospatialData.latitude, 
                geospatialObject.geospatialData.longitude,
                geospatialObject.geospatialData.altitude,
                geospatialObject.geospatialData.quaternion_x,
                geospatialObject.geospatialData.quaternion_y,
                geospatialObject.geospatialData.quaternion_z,
                geospatialObject.geospatialData.quaternion_w)
            anchor!!.pose.toMatrix(modelMatrix, 0)
            Matrix.multiplyMM(modelViewMatrix, 0, viewMatrix, 0, modelMatrix, 0)
            Matrix.multiplyMM(modelViewProjectionMatrix, 0, projectionMatrix, 0, modelViewMatrix, 0)

            geospatialObject.shader.setMat4("u_ModelView", modelViewMatrix)
            geospatialObject.shader.setMat4("u_ModelViewProjection", modelViewProjectionMatrix)
            // 描写
            render.draw(geospatialObject.mesh, geospatialObject.shader, virtualSceneFramebuffer)
        }
    }
}

private data class GeospatialObject(
    val geospatialData: GeospatialData,
    val mesh: Mesh,
    val shader: Shader,
)

ここ記載していて気づいたのですが、地理空間情報から毎回アンカーを作成するのではなく、DBからデータ取得したタイミングでアンカー作成して以降は作成済みのアンカーを描写するとした方がよさそうです。デメリットとして、アンカーの位置がデータ取得時のGeospatial APIの精度に依存してしまいますが、計算量減りますし、Geospatial APIの制度が悪い時にオブジェクトが動き回らなくなるはずなので取得時に生成に修正します。
都度アンカーを生成する方が使用感良かったです。

まとめ

最初に考えた機能は作ることが出来たので、細かなレイアウト調整や追加で欲しくなった機能を追加して、リリースしようかなと思います。リリースしたら機能説明や、使い方などを記載した記事を投稿します。

Discussion