🤳

ARのSNS作ってみる オブジェクト作成~描写編

2024/10/30に公開

完成イメージ

AR空間にメッセージを投稿でき、投降した内容を自身と他者が端末を介して見ることが出来るという完成イメージをしています。

image.png

技術選定

フレームワーク:Android Studio
言語:kotlin
DB:Cloud SQL
ライブラリ等:ARCore、Geospatial API、Canvas

対象端末はAndroidを想定しているので、とりあえずAndroid Studioを使い、言語とDBは気分です。AR機能を使うので、ARCoreを利用し、ユーザが作成したオブジェクトを他ユーザも参照できるようにするため、オブジェクトの絶対位置(緯度、経度、高度)を取得できるGeospatial APIを利用する想定です。またユーザが入力した文字列は画像化してオブジェクトに張り付けるので、Canvasも利用します。

必須機能

①ユーザがポストしたい内容を入力できる。

②画面をタップしたらオブジェクトを描写できる。

③入力された内容が記載されたオブジェクトを描写できる。

④入力された内容(文字列)の長さによってオブジェクトを伸び縮みできる。

⑤作成されたオブジェクトの絶対位置をデータベースで永続化できる。

⑥永続化されたオブジェクトから自身の現在位置の周辺にあるものを取得して描写できる。

⑦現在位置が一定数以上変わったら再度永続化されたオブジェクトを取得して描写できる。

他にも実装したい細かな機能はありますが、利用可能な状態だけを考えるのであれば上記の機能で十分かなと考えています。まだ作成途中なので、上記の各機能の説明は数記事に分割して投稿しようと思います。今回の記事では①~④について説明します。

ユーザにポストしたい内容を入力させる

この機能に関しては単純にEditTextを持つActivityを作成するだけ、特に言うこともないので詳細な説明は省きます。
 AR画面から遷移でき、以下の「メモする」をタップしたら、ARの画面に戻り入力した内容のオブジェクトが描写できるようになるイメージです。

image.png

画面をタップしたらオブジェクトを描写される

AR空間にオブジェクトを配置する上で必要な要素として以下があります。

・オブジェクト
 AR空間に配置するもの。3Dモデル、画像、動画など。

・平面検出
 現実世界の水平面や垂直面を認識して、オブジェクトを配置するための基盤になる。

・点群
 現実世界の物体の表面を構成する点の集合。平面が検出できない複雑な形状の物体に有効。オブジェクトを配置するための基盤になる。

・アンカー
 オブジェクトを現実世界の特定の位置に固定するためのもの。

上記の要素を使いAR空間にオブジェクトを配置していきます。
 
まずは平面、点群の検出を行い視覚化することで、オブジェクトが配置位置を明確にします。平面、点群の検出自体はデフォルトで有効になっていると思うので、検出した結果を受け取り、画面に描写を行います。AR空間への描写はOpenGLを利用するのですが、一から書くと分けわからないので、公式が用意しているサンプルコードを利用します。複雑なところは隠蔽してくれています。

https://github.com/google-ar/arcore-android-sdk/tree/main/samples/hello_ar_kotlin

このサンプルコードで内の「HelloArRenderer」クラスの「onDrawFrame」にて平面と点群の描写を行っています。このメソッドはフレーム毎で呼び出されるため、常に適した平面と点群が描写されます。

//平面の描写
planeRenderer.drawPlanes(
  render,
  session.getAllTrackables<Plane>(Plane::class.java),
  camera.displayOrientedPose,
  projectionMatrix
)

//点群の描写
frame.acquirePointCloud().use { pointCloud ->
  if (pointCloud.timestamp > lastPointCloudTimestamp) {
    pointCloudVertexBuffer.set(pointCloud.points)
    lastPointCloudTimestamp = pointCloud.timestamp
  }
  Matrix.multiplyMM(modelViewProjectionMatrix, 0, projectionMatrix, 0, viewMatrix, 0)
  pointCloudShader.setMat4("u_ModelViewProjection", modelViewProjectionMatrix)
  render.draw(pointCloudMesh, pointCloudShader)
}

この描写をすると以下のようになります。白色の三角形が平面で青色の点が点群です。

image.png

次にオブジェクトの描写です。これも先ほどのサンプルコードを見ていきます。サンプルコードではMeshクラス、Shaderクラス、Textureクラスを利用してオブジェクトの描写を行っています。

  • Meshクラス
    オブジェクトの形状の情報を持つクラスです。サンプルコードだと以下のように定義されています。ここで渡しているobjファイルを変えれば好きな形のオブジェクトを配置できます。objファイルはBlenderなどで作成できます。
    virtualObjectMesh = Mesh.createFromAsset(render, "models/pawn.obj")
    
  • Textureクラス
    テクスチャ(オブジェクトに張り付ける画像)を作成、保持するクラスです。サンプルコードだと以下のような画像をテクスチャとしてして設定していると思います。

image.png
 
3Dオブジェクトの場合、単純に画像を張り付けることが出来ないため、UV展開したものに画像を貼り付けます。サンプルコードのオブジェクトのUV展開図が以下であるため、上記のような画像をテクスチャとして設定しています。

image.png

  • Shaderクラス
    OpenGLのシェーダーを扱うためのクラス。バーテックスシェーダー(頂点ごとに実行)やフラグメントシェーダー(ピクセルごとに実行)の設定や、uniform変数(シェーダープログラムに渡す値)を設定します。

基本的に上記3つのクラスを使いオブジェクトの生成、描写を行います。出力するオブジェクトを変えたいだけであれば、Meshクラスに渡すobjファイルとTextureクラスに渡す画像を差し替えれば変わります。

話がやや脱線しましたが、今回のサブタイトルにある「画面をタップしたらオブジェクトを描写される」について進めていきます。これはそこまで複雑ではなく、タップ時にアンカーを追加し、そのアンカーにオブジェクトを固定すれば良いです。サンプルコード上の「handleTapメソッド」がタップからアンカーを追加する処理で、onDrawFrameにてアンカー分だけオブジェクトが作成されます。
これで画面をタップしたら以下のような形でオブジェクトが描写されます。

image.png

入力された内容が記載されたオブジェクトを描写する

入力される内容は日本語の文字列です。ひらがな、カタカナ、漢字が存在するため、文字をオブジェクト化して描写するというのは難しいです。したがって文字列を画像化してオブジェクトにテクスチャとして張り付けるという方法を取ります。コードは以下です。

import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Canvas
import android.graphics.Paint.Align
import android.graphics.Paint
import android.graphics.Typeface

class MessageTextBitmap (messageText: String) {

    val messageTextBitmap: Bitmap = textToBitmap(messageText, 30f, Color.WHITE)

    private fun textToBitmap(text: String, textSize: Float, textColor: Int): Bitmap {
        val paint = Paint(Paint.ANTI_ALIAS_FLAG)
        paint.textSize  = textSize
        paint.color     = textColor
        paint.textAlign = Align.LEFT
        paint.typeface  = Typeface.DEFAULT_BOLD

        val testList: List<String> =  text.split("\n")

        val longestText = testList.reduce { longestText, text -> if (text.length > longestText.length) text else longestText }
        val width       = (paint.measureText(longestText) + 50f).toInt()
        val height      = ((-paint.ascent() + paint.descent() + 5f) * testList.size + 50f).toInt()

        val image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(image)

        canvas.drawColor(Color.CYAN)

        for (i in testList.indices) {
            val x = (width - paint.measureText(longestText)) / 2f
            val y =
                if (i == 0) {
                    -paint.ascent() + (50f / 2f)
                } else {
                    -paint.ascent() + (50f / 2f) + ((-paint.ascent() + paint.descent() + 5f) * (i))
                }

            canvas.drawText(testList[i], x, y, paint)
        }

        return image
    }
}

テキストを受け取ったら文字数、行数に応じて画像のサイズを動的に設定します。実行すると以下のような画像ができます。

タイトルなし.png

次にテクスチャを張り付けるベースとなるオブジェクトを作成します。右がオブジェクトの形で左がテクスチャ座標です。テクスチャ座標は0~1の相対距離で計算されます。オブジェクトの表面のテクスチャ座標はテクスチャの全体を張り付けられるようにして、オブジェクトの裏面のテクスチャ座標はすべて0,0座標に置いてテクスチャの端の色が適用されるだけにしています。

image.png

このオブジェクトに先ほどの画像をテクスチャとして貼り付けると入力された内容が記載されたオブジェクトを描写できます。

Screenshot_20241020-150957.png

しかし今のままではオブジェクトの大きさが固定なので入力された文字数が長かったり、改行があった場合、画像がオブジェクトのサイズにスケールされてしまいます。

Screenshot_20241020-151102.png

入力された内容(文字列)の長さによってオブジェクトを伸び縮みする

次に画像サイズに合わせてオブジェクトのサイズも動的に変更します。まずは全角の一文字が丁度よく収まるサイズのオブジェクトを作成します。

image.png

次にこのオブジェクトを入力された文字列の画像サイズに合わせて動的に大きさを変えていきます。objファイルはテキストベースで情報を持っています。なのでテキストファイルとして読み込み、書き換えて再度保存するという方法を取ります。
利用したソフトによりますがobjファイルは頂点座標の情報を「v {x} {z} {y}」の形で持っています。この{x}と{y}を変えることでオブジェクトをX軸Y軸に伸び縮みさせることができます。objファイルは頂点座標の他にもテクスチャ座標の情報なども持っていますが、テクスチャ座標は上述した通り相対距離で持っているため、オブジェクトを伸び縮みさせるだけなら何も変えなくて大丈夫です。
objファイルを書き換えるコードは以下の通りです。

import android.content.Context
import android.content.res.AssetManager
import java.io.File
import java.io.InputStream
import kotlin.text.split
import kotlin.text.startsWith
import kotlin.text.toFloat

class MessageTextObject (private val messageTextBitmap: MessageTextBitmap, private val assetManager: AssetManager, private val context: Context) {

   private val baseImageWidth: Double  = 50.0
   private val baseImageHeight: Double = 60.0

   fun convertObjFile() {
       val imageWidth  = messageTextBitmap.messageTextBitmap.width
       val imageHeight = messageTextBitmap.messageTextBitmap.height

       val ratioWidth: Double  = imageWidth.toDouble() / baseImageWidth
       val ratioHeight: Double = imageHeight.toDouble() / baseImageHeight

       val objFile = assetManager.open( "models/message.obj")
       val newObjFileLine = multiplyVertices(objFile, ratioWidth, ratioHeight)

       val newObjFile = File(context.filesDir, "message.obj")
       newObjFile.writeText(newObjFileLine)
   }

   private fun multiplyVertices(objFile: InputStream, xMultiplier: Double = 1.0, yMultiplier: Double = 1.0, zMultiplier: Double = 1.0): String {
       val lines = objFile.bufferedReader().readLines()
       val newLines = mutableListOf<String>()

       for (line in lines) {
           if (line.startsWith("v ")) {
               val parts = line.split(" ")
               if (parts.size >= 4) {
                   val x = parts[1].toFloat() * xMultiplier
                   val z = parts[2].toFloat() * zMultiplier
                   val y = parts[3].toFloat() * yMultiplier
                   newLines.add("v $x $z $y")
               } else {
                   newLines.add(line)
               }
           } else {
               newLines.add(line)
           }
       }

       return newLines.joinToString("\n")
   }
}

全角一文字の時の画像サイズが50×60なので、それをベースサイズとして、入力された文字列の画像がベースサイズから縦横それぞれ何倍の大きさになっているかを求めます。求められた倍率をobjファイルの頂点座標のX、Yにそれぞれ乗算します。これによってオブジェクトが動的に大きさを合わせてくれます。

Screenshot_20241020-144810.png

Screenshot_20241020-145000.png

Discussion