【技術解説】ARアプリ『Finding Serendipity』実現への道
どうも、デザイニウムのMIZUTANI KIRINです!今回、自分が開発に関わったLIFULL HOME’Sさんの「Finding Serendipity」を技術面からどうやって実装したのかなどを紹介していきます。現在(2023/01)このアプリはパブリックテストとして公開していますので、是非ご参加ください。
「Finding Serendipity」とは?
Finding Serendipityは、ユーザーがAR上に残した街の魅力やその時の感情を蓄積し、風景と重ねて表示することで、ひとりでは見つけることができなかった素敵な“偶然”を発見できるARプラットフォームです。
Niantic社のLightship ARDKを使用してAR体験を実現しています。気になった場所の写真、動画を撮って共有したり、VPSを使用して街中にあるWayspotと呼ばれるランドマーク的なスポットを認識して絵を描くことができます。このアプリで実現しているARやその他技術の実装方法についてポイントポイントで説明していきます。
プロトタイプアプリの組み込み
Finding Serendipityの開発では、AR体験を実現するためにデザイニウムで作成したプロトタイプアプリを2つを組み込んでいます。1つは、GPS/VPSアンカーをAR空間上に置くことができるShare Notes、もう1つは、AR空間上にドローイングができるDrawing Toolです。この2つのアプリについて紹介をしてきます。
Share Notes
Share Notesは、GPS/VPSアンカーをAR空間上に置くことができるプロトタイプアプリです。AR空間上にアンカーと呼ばれるオブジェクトを置き、それに画像・動画・テキスト・音声を紐付けておくことができます。アンカーの情報はサーバに蓄積されるため、他の人が投稿したアンカーも見ることができます。Finding Serendipityではこのアプリをベースに開発を進めていきました。
このアプリには、GPSとVPSの2種類のアンカーがあります。GPSアンカーは緯度経度を保存するようにしており、VPSアンカーはARDKの機能を使ってPayloadと呼ばれるバイナリブロブを保存しています。
PayloadはToString()すると以下のような文字列になっており、これを復元することによってAR空間上の正しい位置にアンカーを配置できます。
ChUIsKnGy/CRvuqHARDn6pq21LT2+j8Ylsf/sLJaKiYKFAjU6f/m8cjjpScQrvephK3my8M1Egk....
VPSのアンカー配置に関しては、ARDKの精度の高さもありShare Notesからの変更点はさほどなかったのですが、GPSアンカーについてはGPSのブレなど問題などもあり、多くカスタマイズしていきました。そのGPSアンカーの最適化方法については長くなるため後ほどまとめて紹介します。
Drawing Tool
Drawing Toolは、ARDKを使用したAR空間上に線を描き、描いたもののアニメーションが作れるアプリです。このアプリをカスタマイズして、ARペンとしてFinding Serendipityに組み込みをしています。
線を描く実装はLinerendererを使用しており、下画像のように色やブラシの種類も変えられるようにしています。
ブラシの種類によってはLineの太さを途中で変える必要があるブラシもあります。それは、AnimationCurveを使用して実現します。最大の太さをユーザが選択できるようにしたかったため、Inspectorで固定の幅に指定するのでなく、コードで書いています。
以下のように単純ではあるのですが、始まりと終わりを0に指定して、両端0.1ポイントのところでユーザが指定した太さになるように設定をしています。このように設定すると、上図右のような線が引くことができるようになります。
AnimationCurve curve = new AnimationCurve();
curve.AddKey(0.0f, 0f);
curve.AddKey(0.1f, 1f * _lineWidth * 0.5f);
curve.AddKey(0.9f, 1f * _lineWidth * 0.5f);
curve.AddKey(1.0f, 0f);
lineRenderer.widthCurve = curve;
Tips
Finding Serendipityでは、上記以外にも様々なAsset、APIを使用しています。その中でもWeb上に情報が少ない技術について紹介していきたいと思います。
GPSアンカーの最適化方法
上で少し触れましたが、GPSはブレなど問題が多くそれをどう最適化していったかを説明をしていきます。
まず、GPSアンカーと読んでいますが、これはGPSの緯度経度+スマホの向き情報によって作成されたアンカーのことを指しています。このGPSアンカーは、GPSの精度によって大きく左右されてしまいます。Mapアプリで見たとき、自分の位置が行ったり来たりするのを見たことがあると思います。特に屋外だと精度が高く位置取得できますが、屋内だとどうしても精度が低くなってしまいます。このような問題をできる限り少なくするのがGPSアンカーの肝となる部分です。
Finding Serendipityでは、AR表示が始まるときに初めにGPSのキャリブレーションを入れて、東西南北、ユーザの現在地(緯度経度)の取得をしているんですが、それだけだとどうしてもサーバから取得したアンカーの位置ズレが発生してしまいます。そのズレを解消するために、以下の処理などを入れて最適化をさせました。
ユーザの位置(緯度経度)のスムージング
ユーザが移動せず同じ位置にいても位置が変わってしまう場合があるのですが、それがあるとアンカー作成時や取得時にアンカーの位置が大きくズレることとなります。それを少なくするために、指定したフレーム分平均値をとってスムージングを行っています。Mathdを使用して緯度経度を保存
開発途中まではVector2で緯度経度の値をやり取りしていたのですが、それだと必要な小数点が省かれてしまっていることに気付き、MathD.Vector2dを使用することにしました。これを使うとDoubleでVector2を扱うことができ、より正確な値で緯度経度を保存できます。定期的なアンカーの位置調整
アンカーの位置は、GPSキャリブレーション時のユーザの位置を原点に緯度経度の計算をしてUnity座標で配置させているのですが、それだとどうしても遠くのアンカーになればなるほどズレ大きくなってしまうため、定期的に(ユーザが10m移動するたびに)アンカーの位置を再計算して配置するようにしています。定期的な東西南北の角度取得
緯度経度だけでなく、アンカーの位置には精度の高い東西南北を取得も必要になってきます。GPSキャリブレーション時だけで判断をしているとそのときに精度が低いとすべてのアンカーの位置がずれてしまいます。
それを防ぐために定期的にGPSキャリブレーションを裏で動かし、そのときのコンパスの精度と前のを比較して精度が高い方を採用して精度を高めています。
ちなみにコンパスの精度はInput.compass.trueHeadingで取得できます。
H3
アンカーは作成時はサーバに位置情報を保存して、取得時にはその情報をサーバ側から読み込むという仕組みにしています。
ここで問題となってくるのが、サーバに位置情報として緯度経度だけを保存した場合、アンカーデータ取得時にサーバ側で負担が増えてしまうということです。どういうことかというと、アンカー作成時に緯度経度だけ保存しておくと、取得時にユーザがいる位置から何m以内のアンカーを取得するといった処理が必要になってくるのですが、その際、何m以内にという計算をサーバ側ですべてのアンカー情報に対して行わないといけなくなり、かなり負担が増え、取得時に時間がかかってしまいます。
それを避けるためにH3というシステムを使用することにしました。
H3はUberが開発した六角形の階層型地理空間インデックスシステムになります。簡単に説明すると、地球全体をある一定の大きさの六角形でエリア分けして、一つ一つの六角形にインデックスを割り当てたシステムがH3になります。詳しくはQiita( https://qiita.com/gshirato/items/d8cc928c4131f3292b14 )にまとめられていますので、そちらをみるとわかりやすいと思います。
例えば、五反田駅だとresolution(六角形の大きさ)が9の場合、892f5aace47ffffというIDを取得することができます。
(H3 Map: https://wolf-h3-viewer.glitch.me/)
このH3を使用すると、上記のような何mかどうかの計算が不要になり、指定されたインデックスのアンカーだけをアプリ側に返せばいいだけになります。
C#で使用できるH3のラッパー的なコードはいくつかあるんですが、今回はテストをしてUnityで使用できることが確認できたh3netを使用しました。
h3netには一応サンプルは入ってはいるんですが、かなりわかりにくいのでサンプルを載せておきます。
緯度経度からH3インデックスへ変換
using H3Lib;
using H3Lib.Extensions;
public class H3Manager : MonoBehaviour
{
private void Start()
{
float lat = 35.6271259423023f;
float lon = 139.7236992976794f;
string h3Index = GeoToH3(lat, lon, 9);
Debug.Log("[Geo:" + lat + ", " + lon + "] → [H3 : " + h3Index + "]");
}
public string GeoToH3(double latitude, double longitude, int resolution)
{
GeoCoord geoCoord = new GeoCoord(
((decimal)latitude).DegreesToRadians(),
((decimal)longitude).DegreesToRadians());
H3Index h3Index = Api.GeoToH3(geoCoord, resolution);
return h3Index.ToString();
}
}
H3インデックスから緯度経度へ変換(緯度経度はセルの中央)
using H3Lib;
using H3Lib.Extensions;
public class H3Manager : MonoBehaviour
{
private void Start()
{
string h3Index = "892f5aace47ffff";
Vector2 geoPoint = H3ToGeo(h3Index);
Debug.Log("[H3 : " + h3Index + "] → [Geo: " + geoPoint.x + ", " + geoPoint.y + "]");
}
public Vector2 H3ToGeo(string h3IndexStr)
{
if (h3IndexStr == "") return Vector2.positiveInfinity;
if (h3IndexStr.Length == 15) h3IndexStr = "0" + h3IndexStr;
H3Index h3Index = h3IndexStr.ToH3Index();
GeoCoord geoCoord = new GeoCoord();
Api.H3ToGeo(h3Index, out geoCoord);
return new Vector2(
(float)geoCoord.Latitude.RadiansToDegrees(),
(float)geoCoord.Longitude.RadiansToDegrees());
}
}
隣のセルのH3インデックスを取得
using H3Lib;
using H3Lib.Extensions;
public class H3Manager : MonoBehaviour
{
private void Start()
{
string[] neighborH3Indexs = GetNeighborH3Indexs("892f5aace47ffff");
for (int i = 0; i < neighborH3Indexs.Length; i++)
{
Debug.Log(neighborH3Indexs[i]);
}
}
public string[] GetNeighborH3Indexs(string centerH3Index)
{
if (centerH3Index == "") return null;
if (centerH3Index.Length == 15) centerH3Index = "0" + centerH3Index;
H3Index h3Index = centerH3Index.ToH3Index();
// 北北東のセルから右回りで順番に並べるとこの順番になる
var h3_0 = h3Index.NeighborRotations(Direction.J_AXES_DIGIT, 0);
var h3_1 = h3Index.NeighborRotations(Direction.IJ_AXES_DIGIT, 0);
var h3_2 = h3Index.NeighborRotations(Direction.I_AXES_DIGIT, 0);
var h3_3 = h3Index.NeighborRotations(Direction.IK_AXES_DIGIT, 0);
var h3_4 = h3Index.NeighborRotations(Direction.K_AXES_DIGIT, 0);
var h3_5 = h3Index.NeighborRotations(Direction.JK_AXES_DIGIT, 0);
string[] neighbors = new string[6];
neighbors[0] = h3_0.Item1.ToString();
neighbors[1] = h3_1.Item1.ToString();
neighbors[2] = h3_2.Item1.ToString();
neighbors[3] = h3_3.Item1.ToString();
neighbors[4] = h3_4.Item1.ToString();
neighbors[5] = h3_5.Item1.ToString();
for (int i = 0; i < neighbors.Length; i++)
{
neighbors[i] = neighbors[i].Substring(1, neighbors[i].Length - 1);
}
return neighbors;
}
}
m4aの再生
Finding Serendipityには、Apple MusicのPreviewの投稿/再生ができるミュージックアンカーがあります。話はARとはちょっと離れますが、そのPreviewの再生方法についてここで書きたいと思います。
Apple Music APIを使うとアーティストや曲検索で様々な楽曲の情報が取得できるんですが、その中に曲の一部が聞けるPreviewのURLも取得することができます。しかし、そのファイル形式はUnityで対応していないm4aになっています。ffmpegを使うとアプリ内部でm4aをwavにも変換できるようですが、ffmpegはライセンス的に今回使用できませんでした。
いろいろと試行錯誤した結果、最終的にWebViewを使用してm4aの再生させるという結論に達しました。かなりイレギュラーな実装方法だとは思うのですが、htmlではaudioタグという便利なタグがありそれを使ってm4aの再生をしています。
WebViewはunity-webviewを使用しました。m4aファイルの再生時のコードは以下のように実装しています。
public WebViewObject webViewObject;
public void PlaySound(string soundUrl)
{
// webViewが見えないように1x1pxで表示
webViewObject.SetMargins(0, 0, Screen.width - 1, Screen.height - 1);
webViewObject.SetTextZoom(100);
webViewObject.SetVisibility(true);
// htmlを作成
string html = "<!DOCTYPE html><html><head><title></title></head><body><audio loop controls autoplay><source src=\"" + soundUrl +"\"></audio></body></html>";
webViewObject.LoadHTML(html, "");
}
最後に
技術的にどう実装しているのか部分的にですが説明をしました。いかがだったでしょうか?参考になれば幸いです。
編集後記
こんにちは!広報のマリコです。先日開催されたXRKaigiでも『Finding Serendipity』を弊社のブースで展示させて頂き、多くの来場者の方に体験して頂き、参加者の方にもとても好評でした😊様々な場所にアイコンや画像を残せる楽しさが昔大好きだった「セカイカメラ」を彷彿とさせる部分があり、個人的にもとてもワクワクするARアプリです📱✨iPhoneをお使いの方はパブリックテストとして体験することが出来ますので、ぜひ気軽にコチラからダウンロードして楽しんでみてください❗
Designium
Official website
Interactive website
Twitter
Instagram
Facebook