こんにちは!セーフィー第2開発部のAndroidエンジニアのジェローム(@yujiro45)です。 この記事はセーフィー株式会社 Advent Calendar 2024の12月13日の記事です!
2024年2月1日、360°全方位を広範囲に撮影できる魚眼レンズを搭載した新しいカメラ「Safie GO 360」が公開されました。このカメラのプレビューをアプリで表示するために、3Dモデルを使用する必要がありました。この記事では、Androidでどのようにこれを実現したかを説明します。٩( ᐛ )و
半天球カメラの動画再生の表示する方法
Safieビューアーのストリーミング画面はまだCompose化されていないので、この記事ではxmlの実装を紹介しますが、composeでも同じロジックです。
1. 3Dモデルを表示する
👇10月に書いた記事に関する記事と深く関連していますので、まだ読んでいない方はぜひご覧ください〜。 engineers.safie.link
360°全方位を広範囲に撮影した映像を表示するために、半球の3Dモデルが必要です。カスタム3Dモデルはコードで作成することもできますが、あまりにも複雑で時間がかかるため、Blenderで3Dモデルを作成し、それを使用することにしました。
前回の記事で書いたように、SceneViewでは.glb
と.glTF
ファイルしかサポートされていません。どちらか一方を選択する必要がありますが、.glb
ファイルの方が軽く、効率的であるため、.glb
を使用することにしました。
sceneView.apply { // 3Dモデル作成 val modelNode = ModelNode( modelInstance = modelLoader.createModelInstance( R.raw.hemisphere ) ) // モデルの位置と向きの設定 modelNode.position = Position(x = 0f, y = 0f, z = 0f) modelNode.rotation = Rotation(x = 90f, z = 180f) // モデルを追加する addChildNode(modelNode) }
2. テクスチャマッピング
カメラの動画再生ではExoPlayer使用しています。
ExoPlayerで再生している映像をもとに、3Dモデルのテクスチャにマッピングするために、ARでビデオを表示した時に作成したExoPlayerVideoMaterial
を使います。
sceneView.apply { // 3Dモデル作成 val modelNode = ModelNode( modelInstance = modelLoader.createModelInstance( R.raw.hemisphere ) ) // 動画再生によるExoPlayer作成 val exoPlayer = ExoPlayer... // ExoPlayerのカスタムVideoMaterial作成 val exoPlayerVideoMaterial = ExoPlayerVideoMaterial( engine, exoPlayer, materialLoader ) // モデルの位置と向きの設定 modelNode.position = Position(x = 0f, y = 0f, z = 0f) modelNode.rotation = Rotation(x = 90f, z = 180f) // モデルを追加する addChildNode(modelNode) }
上記のコードをそのまま使うと、SceneViewが3Dモデルの背面にテクスチャをマッピングします。そのため、コンテンツが逆方向にマッピングされるような見た目になってしまいます。
理想 | 現実 |
---|---|
これを修正するには、GitHubで説明されているように、モデルのx 軸上のスケールを反転させる必要があります。
modelNode.scale = Scale(x = -1f, y = 1f, z = 1f)
いい感じですね!🥳
3. 画角
Safie GO 360カメラは、壁と天井の2つのpositionに設置できます。
横向き | 下向き |
---|---|
撮影向きによって、3Dモデルを回転させる方法は異なります。壁に設置された場合、動きは頭の動き(上下、左右)に従います。 しかし、天井に設置された場合は、3Dモデルを宙返りしないような制限を入れる必要があります。
SceneViewのデフォルトの挙動を使用すると、3Dモデルをどの方向にも回転させ、任意のスケールでズームイン/ズームアウトができますが、ユーザーが3Dモデルの背面を見ることができないようにし、また無限にズームインやズームアウトできないようにした方がいいです。ソースコードを確認すると、SceneViewのCameraManipulator
パラメーターはデフォルトで設定されていることになっています。
open class SceneView @JvmOverloads constructor( // ... /** * Helper that enables camera interaction similar to sketchfab or Google Maps. * * Needs to be a callable function because it can be reinitialized in case of viewport change * or camera node manual position changed. * * The first onTouch event will make the first manipulator build. So you can change the camera * position before any user gesture. * * Clients notify the camera manipulator of various mouse or touch events, then periodically * call its getLookAt() method so that they can adjust their camera(s). Three modes are * supported: ORBIT, MAP, and FREE_FLIGHT. To construct a manipulator instance, the desired mode * is passed into the create method. */ cameraManipulator: CameraGestureDetector.CameraManipulator? = createDefaultCameraManipulator(sharedCameraNode?.worldPosition), // ... ) : SurfaceView(context, attrs, defStyleAttr, defStyleRes)
望む結果を達成するには、それを削除する必要があります。しかし、ソースコードを編集できないため、SceneViewを継承した新しいクラスを作成し、cameraManipulator
のパラメーターをnull
に設定する必要があります。
class CustomSceneView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0 ) : SceneView( context, attrs, defStyleAttr, defStyleRes, cameraManipulator = null // <--- ここ )
ただし、cameraManipulator
をnull
に設定すると、ユーザーのすべてのジェスチャーが削除されてしまいます。ということは、自分のジェスチャーを開発するしかありません。
ジェスチャー
SceneView
には、カスタムジェスチャーを実装するためにsetOnGestureListener()
があります。
fun setOnGestureListener( onDown: (e: MotionEvent, node: Node?) -> Unit = { _, _ -> }, onShowPress: (e: MotionEvent, node: Node?) -> Unit = { _, _ -> }, onSingleTapUp: (e: MotionEvent, node: Node?) -> Unit = { _, _ -> }, onScroll: (e1: MotionEvent?, e2: MotionEvent, node: Node?, distance: Float2) -> Unit = { _, _, _, _ -> }, onLongPress: (e: MotionEvent, node: Node?) -> Unit = { _, _ -> }, onFling: (e1: MotionEvent?, e2: MotionEvent, node: Node?, velocity: Float2) -> Unit = { _, _, _, _ -> }, onSingleTapConfirmed: (e: MotionEvent, node: Node?) -> Unit = { _, _ -> }, onDoubleTap: (e: MotionEvent, node: Node?) -> Unit = { _, _ -> }, onDoubleTapEvent: (e: MotionEvent, node: Node?) -> Unit = { _, _ -> }, onContextClick: (e: MotionEvent, node: Node?) -> Unit = { _, _ -> }, onMoveBegin: (detector: MoveGestureDetector, e: MotionEvent, node: Node?) -> Unit = { _, _, _ -> }, onMove: (detector: MoveGestureDetector, e: MotionEvent, node: Node?) -> Unit = { _, _, _ -> }, onMoveEnd: (detector: MoveGestureDetector, e: MotionEvent, node: Node?) -> Unit = { _, _, _ -> }, onRotateBegin: (detector: RotateGestureDetector, e: MotionEvent, node: Node?) -> Unit = { _, _, _ -> }, onRotate: (detector: RotateGestureDetector, e: MotionEvent, node: Node?) -> Unit = { _, _, _ -> }, onRotateEnd: (detector: RotateGestureDetector, e: MotionEvent, node: Node?) -> Unit = { _, _, _ -> }, onScaleBegin: (detector: ScaleGestureDetector, e: MotionEvent, node: Node?) -> Unit = { _, _, _ -> }, onScale: (detector: ScaleGestureDetector, e: MotionEvent, node: Node?) -> Unit = { _, _, _ -> }, onScaleEnd: (detector: ScaleGestureDetector, e: MotionEvent, node: Node?) -> Unit = { _, _, _ -> } )
イベントがたくさんありますが、半天球カメラの場合は以下の実装しか必要がありませんでした。
onMove
→ ドラッグ操作の時カメラを回転するためにonScale
→ 最小・最大ズームのためにonSingleTapConfirmed
→ プレイヤーのコントロールボタンを表示・非表示するためにonDoubleTap
→ ダブルタップ位置へセンタリングするために
最小・最大ズームの距離計算
ズームイン/アウトの動きは基本的にカメラが軸に沿って移動することで実現されます。最大ズームの時には、カメラの位置を(0, 0)に設定し、最小ズーム(最大ズームアウト)の場合はカメラの移動範囲をモデルの外側が映らないように制限したいです。
最大ズームは大きな問題ではありませんでした。なぜなら、カメラの位置(x, y)が0
より下に行かないように制限すれば上手くいきました。しかし、最小ズームの場合は、カメラが到達できる最遠のポイントを決める必要があります。これは球体の半径とカメラのFOV
の設定を元に算出できます。
tan(FOV / 2) = r / d
→ d = r / tan(FOV / 2)
動画再生領域が縦長の場合に、最小ズーム状態で左右が見切れてしまいました。そのため、画面のアスペクト比を考慮する必要がありました。
// 球の半径を設定する private val aspectRatio: Float get() { val cameraViewAspectRatio = width.toFloat() / height.toFloat() return if(cameraViewAspectRatio >= 1f) { // 横長の画面の場合は単位球の半径 1 をそのまま利用する 1f // 球体は半径を1にしておくと計算が楽 } else { // 縦長の画面の場合は、 FOV が vertical な設定の関係から、半径相当のサイズをアスペクト比を利用して変換する 1f / cameraViewAspectRatio } } // z 軸上のカメラと半球面モデルの最大距離 private val maxDistance: Float get() = aspectRatio / tan(Math.toRadians(FOV / 2f)).toFloat()
結果を見てみましょう!
横長 | 縦長 |
---|---|
ズームできました〜🥳
回転と移動
Androidでは、GestureDetector
を使うと、ユーザーが画面にタッチした位置の(x, y)座標を取得できます。デフォルトでは、原点 (0, 0) はビューの左上隅にあります。
ユーザーがドラッグしている方向を特定するには、2つのポイント間のVectorを計算する必要があります:
AB(x2-x1, y2-y1)
の公式を使うと、ユーザーがスワイプした方向を特定することができます。
fun drag(from: Pair<Float, Float>, to: Pair<Float, Float>) { val previousPointX = from.first val previousPointY = from.second val currentPointX = to.first val currentPointY = to.second val dx = currentPointX - previousPointX val dy = currentPointY - previousPointY when(cameraControlMode) { CameraControlMode.CEILING -> { // カメラを回転する } CameraControlMode.WALL -> { // カメラを回転する } } }
また、カメラの回転角度が制限内に収まるように調整しないといけないです。
球体の半径(r)と現在の距離(d)を元に最大回転角(α)を算出できます。
α = atan2(r, d)
しかし、カメラの回転は画面中心を基準に行われるため、 FOV に応じた調整が必要です。
α = atan2(r, d) - (FOV / 2)
/** * カメラの回転角度が制限内に収まるように調整を行う * * @param x x軸中心での回転角度 (deg) * @param y y軸中心での回転角度 (deg) * @return Pair<調整後の x 軸中心での回転角度 (deg), 調整後の y 軸中心での回転角度 (deg)> */ private fun adjustCameraNodeRotation(x: Float, y: Float): Pair<Float, Float> { // 距離と球体の半径を元に最大回転角を算出 val rotationLimit = atan2(1f, actualDistance) val rotationLimitDegree = Math.toDegrees(rotationLimit.toDouble()) // 球体外の写像を避けるために、 FOV を元に回転角をさらに制限 val xRotationLimit = if(rotationLimitDegree > (FOV / 2f)) { (rotationLimitDegree - (FOV / 2f)).toFloat() } else { 0f } when(cameraControlMode) { CameraControlMode.CEILING -> { // x 軸の縦回転は -90 deg から 0 deg の範囲となる val xAxisRotation = x.coerceIn(X_AXIS_ROTATION_LIMIT, X_AXIS_ROTATION_LIMIT + xRotationLimit) return Pair(xAxisRotation, y) } CameraControlMode.WALL -> { // FOV は縦方向を基準としているため、 y 軸方向の横回転はアスペクト比を元に変換した FOV 値を算出する val aspectRatio = width.toFloat() / height.toFloat() val hFOVHalf = Math.toDegrees(atan2(aspectRatio * tan(Math.toRadians(FOV / 2f)), 1.0)).toFloat() val yRotationLimit = if(rotationLimitDegree > hFOVHalf) { (rotationLimitDegree - hFOVHalf).toFloat() } else { 0f } val xAxisRotation = x.coerceIn(-xRotationLimit..xRotationLimit) val yAxisRotation = y.coerceIn(-yRotationLimit..yRotationLimit) return Pair(xAxisRotation, yAxisRotation) } } }
横向き | 下向き |
---|---|
これもいい感じですね!🥳
タップ位置へセンタリング
Safie Viewerでは、3Dモデルをダブルタップするとダブルタップした位置へセンタリングするという機能があります。3Dモデルの外側だと、普通に真ん中に最大ズームします。 それを実現するために、SceneViewのCollisionSystemを使用することで、ユーザーが3Dモデルをタップした位置を検知します。
setOnGestureListener( onDoubleTap = { motionEvent, _ -> val hitTest = collisionSystem.hitTest(motionEvent).firstOrNull { it.node == modelNode } if (hitTest != null) { // 3DモデルのhitTest.pointの(x, y)をTapした! } else { // 3Dモデル外部をタップした } }, )
横向き | 下向き |
---|---|
ただ、SceneViewのCollision Systemは四角いボックスとして機能されています。そのため、3Dモデルが四角形でない場合でも外側をクリックしてもタップイベントはモデルに当たることになります。
3Dモデルは半球形なので、外側にいる場合でも3Dモデルでタッチイベントがトリガーされてしまいました。
この問題を修正するために、3Dモデルの前に平面を配置しました。そして、ユーザーのタッチイベントのx座標とy座標に基づいて、それが3Dモデルの内側か外側かを知ることができます。
タップイベントは点が円内にあるかを確認するには、円の中心が位置 (0, 0) にあると仮定して、次の式を適用する必要があります:
x² + y² ≤ d
/** * 3Dモデルの外側をタップしているかどうか * @param x タップしたx座標 * @param y タップしたy座標 */ private fun isOutside3dModel(x: Float, y: Float): Boolean { // 3Dモデルの外側をタップしている場合、nullにならない val outsideModelTouchPlaneHitTest = collisionSystem.hitTest(x, y) .firstOrNull { it.node == outsideModelTouchPlaneNode } ?: return false // ヒットテストの座標 (x, y) が半径 1 以内にある場合、3Dモデルのタップしている val isTouchInside = outsideModelTouchPlaneHitTest.point?.let { it.x.pow(2) + it.y.pow(2) <= 1 } ?: false return !isTouchInside }
Before | After |
---|---|
まとめ
半天球カメラの動画再生を表示する機能を開発するのは大きなチャレンジですが、たくさん勉強になりました。カスタムジェスチャーの開発は簡単ではなく、三角関数の知識が多く必要でした。しかし、SceneViewを使用すると、衝突システムなどの多くの便利な機能が提供され、希望するものを開発するのが楽になります。今回実装してみた結果もとても良く、スムーズです!
この記事を最後まで読んでいただきありがとうございます。 また、この機能の開発に多大な協力をしてくれた同僚に本当に感謝しています。 このような面白い機能を一緒に開発したい方は、ぜひ以下のリンクをご確認ください!