はじめに

今回はImmersalとPLATEAUを組み合わせて、現実の広い空間に巨大なAR物体を描画することに挑戦しました。
作成の過程で気づいたことを紹介します。

ImmresalはVPSと呼ばれる技術に基づいたARクラウドサービスです。
弊社が以前から注目しているサービスの1つで、既に検証記事としてまとめてあります。
【参考リンク】:サンプルからひも解くImmersal

PLATEAUは国土交通省が行っている、日本の都市を3Dデータ化するプロジェクトです。
東京23区を皮切りに、オープンデータとして公開されています。


デモ

ImmersalとPLATEAU を組み合わせることで、実際の街に巨大なARキャラクターが出現するような表現を容易に実現できます。


バージョン情報

Unity 2021.1.0f1
UniRx Version 7.1.0
Immersal SDK v 1.12.1


PLATEAU導入

まずはPLATEAUの導入について見ていきます。最初に、自身が使いたいエリアを特定する必要があります。
エリアについては下記サイトで自身の区画の番号を確認できます。
【参考リンク】:3D都市モデル(Project PLATEAU)東京都23区(FBX 2020年度)

区画の特定ができたらデータをダウンロードします。
先ほどのデモにおいてはUnityと相性の良いFBX形式のデータを使用しました。


作成の流れ

デモの巨大AR作成の流れは下記です。

①Immersalで特定の建造物を含むマップを作成する
②Unityエディター上で都市3Dモデルと作成したマップの大まかな位置合わせを行う

都市3Dモデルの調整、デバッグツールの作成
④キャラクターを配置
⑤実機にて確認、調整


それぞれの過程を順に追って説明します。


①Immersalで特定の建造物を含むマップを作成する

導入等については下記にまとめているので割愛します。
【参考リンク】:サンプルからひも解くImmersal

今回、マップ化する現地の環境は下記です。

【引用元】:GoogleMap

そして、作成したマップデータをglbファイル形式で出力したものが下記です。

特徴点の多い歩道橋に加えて、周囲の建物の形状もなるべく内包するように撮影を行いました。


②Unityエディター上で都市3Dモデルと作成したマップの大まかな位置合わせを行う

マップが完成したら都市3Dモデルとの位置合わせを行います。
先ほどのマップに内包した建物をViewerから探します。
自身の選択した区画の中から、ユニークな形のビルを見つけて、
Viewerと交互に照らし合わせながら特定するのが良いかと思います。

後ほど実機上で細かい位置合わせを行うのでここは大雑把な位置合わせでOKです。


③都市3Dモデルの調整、デバッグツールの作成

配置を終えたら 都市3Dモデルの調整とデバッグツールの作成を行います。
まずは、 都市3Dモデルの調整からです。この調整はデバッグツールの作成にも関連して必要となります。

ダウンロードしたFBXは空の親オブジェクトに”建物ごとのメッシュの子オブジェクト”が多数置かれています。
この親オブジェクトに問題があるため、
“都市3Dモデルの親オブジェクトの原点”を位置合わせに使用した建物に合わせる という調整が必要となります。


この調整がなぜ必要かは、下記画像をもとに説明していきます。

画像上に小さく映っている白いモデルが大量の都市3Dモデルです。
親オブジェクトを選択した状態のシーンをキャプチャしたものですが、
親オブジェクトの原点が都市3Dモデルまでかなり距離がある状態となっています。

これでは都市3Dモデル全体の位置調整が難しいです。
特に回転に関しては原点と個々の都市3Dモデルの距離が大きくなるにつれて、
オブジェクトの移動距離も大きくなります。

そこで、今回はEditor拡張を作成し、
“都市3Dモデルの親オブジェクトの原点”を位置合わせに使用する建物に合わせる という処理を自動化しました。

using UnityEditor;
using UnityEngine;

/// <summary>
/// 都市3Dモデルの調整
/// </summary>
public class PlateauArrange : EditorWindow
{
    private Object _source;
    private float _range = 100;

    [MenuItem("MyTools/PLATEAU調整ツール")]
    private static void ShowWindow()
    {
        var window = GetWindow(typeof(PlateauArrange));
        window.position = new Rect(Screen.width / 2, Screen.height / 2, 300, 150);
    }

    void OnGUI()
    {
        GUILayout.Space(25);

        _source = EditorGUILayout.ObjectField("原点とするオブジェクト", _source, typeof(GameObject), true);

        GUILayout.Space(25);

        _range = EditorGUILayout.Slider("オブジェクトを残す範囲(m)", _range, 10, 1000);

        GUILayout.Space(25);

        //====================
        // 設定反映ボタン
        //====================
        if (GUILayout.Button("原点を置換し範囲外のオブジェクト削除"))
        {
            if (_source == null)
            {
                Debug.LogError("オブジェクトを選択してください");
                return;
            }

            SetUseArea();
        }
    }

    /// <summary>
    /// 使用エリアを設定する
    /// </summary>
    private void SetUseArea()
    {
        //選択したオブジェクト
        GameObject targetObject = (GameObject) _source;

        //選択したオブジェクトのBoundsから原点座標を取得し親となるオブジェクトを生成
        if (!targetObject.TryGetComponent<MeshRenderer>(out var mr))
        {
            Debug.LogError("選択したオブジェクトにMeshRendererが存在しません");
            return;
        }
        var pos = mr.bounds.center;
        var newParent = new GameObject("PLATEAU_Parent");
        newParent.transform.position = pos;

        //選択したオブジェクトの親の配下のオブジェクトを全て新しい親に移動
        var oldParent = targetObject.transform.parent;
        var list = oldParent.GetComponentsInChildren<Transform>();
        foreach (var child in list)
        {
            if (child.TryGetComponent<MeshRenderer>(out var childMr))
            {
                //Boundsから座標算出
                var childPos = childMr.bounds.center;
                //任意の距離以上離れているゲームオブジェクトは削除
                var dis = Vector3.Distance(childPos, newParent.transform.position);
                if (dis > _range)
                {
                    DestroyImmediate(child.gameObject);
                }
            }

            if (child != null)
            {
                child.SetParent(newParent.transform);
            }
        }

        //古い親削除
        DestroyImmediate(oldParent.gameObject);
    }
}


下記GIF画像の通り、指定したオブジェクトの箇所が親オブジェクトの原点となりました。
ついでに指定範囲外の不要な都市3Dモデルを削除する処理も加えてあります。


この原点の調整後に活用可能なツールも作成しました。
下記GIFがEditor上で確認した様子です。
位置合わせに利用する建物を原点としたことで、回転の調整が楽になりました。

下記コードです。
あくまで即席のデバッグツールなので、設計的な観点からの責務の切り分けなどは行っていません。

using UniRx;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// デバッグ用UI
/// </summary>
public class DebugUI : MonoBehaviour
{
    [SerializeField] private Slider _sliderPosX, _sliderPosY, _sliderPosZ, _sliderRotY;
    [SerializeField] private Text _textPosX, _textPosY, _textPosZ, _textRotY;
    [SerializeField] private Button _hideButton,_showButton;
    [SerializeField] private Toggle _materialToggle;
    [SerializeField] private GameObject _debugUiParent;
    [SerializeField] private GameObject _immersalMesh;
    [SerializeField] private Transform _plateauParentTransform;
    [SerializeField] private Material _occulusionMaterial, _defaultMaterial;

    private Vector3 _starLocalPos;
    private Vector3 _starLocalEuler;

    private const float POS_VALUE_RANGE_ABS = 50;
    private const float ROT_VALUE_RANGE_ABS = 360;
    
    void Start()
    {
        //非表示ボタン押下
        _hideButton.OnClickAsObservable()
            .Subscribe(_ =>
            {
                _debugUiParent.SetActive(false);
                _showButton.gameObject.SetActive(true);
                _immersalMesh.SetActive(false);
            })
            .AddTo(this);
        
        //表示ボタン押下
        _showButton.OnClickAsObservable()
            .Subscribe(_ =>
            {
                _debugUiParent.SetActive(true);
                _showButton.gameObject.SetActive(false);
                _immersalMesh.SetActive(true);
            })
            .AddTo(this);

        //マテリアル切り替えのトグル変更
        _materialToggle.OnValueChangedAsObservable()
            .Subscribe(toggleValue =>
            {
                foreach (Transform child in _plateauParentTransform)
                {
                    if (child.TryGetComponent(out MeshRenderer mr))
                    {
                        mr.material = toggleValue ? _occulusionMaterial : _defaultMaterial;
                    }

                }
            }).AddTo(this);

        //初期値
        _starLocalPos = _plateauParentTransform.localPosition;
        _starLocalEuler = _plateauParentTransform.localEulerAngles;
        _sliderPosX.value = 0.5f;
        _sliderPosY.value = 0.5f;
        _sliderPosZ.value = 0.5f;
        _sliderRotY.value = 0.5f;
        _textPosX.text = _starLocalPos.x.ToString();
        _textPosY.text = _starLocalPos.y.ToString();
        _textPosZ.text = _starLocalPos.z.ToString();
        _textRotY.text = _starLocalEuler.y.ToString();

        //X座標操作のSlider
        _sliderPosX.OnValueChangedAsObservable()
            .Subscribe(value =>
            {
                //値の整形
                var arrangeValue = Mathf.Floor((value - 0.5f) * 100) / 100 * POS_VALUE_RANGE_ABS;
                _textPosX.text = SetPositionX(arrangeValue).ToString();
            })
            .AddTo(this);
        
        //Y座標操作のSlider
        _sliderPosY.OnValueChangedAsObservable()
            .Subscribe(value =>
            {
                //値の整形
                var arrangeValue = Mathf.Floor((value - 0.5f) * 100) / 100 * POS_VALUE_RANGE_ABS;
                _textPosY.text = SetPositionY(arrangeValue).ToString();
            })
            .AddTo(this);
        
        //Z座標操作のSlider
        _sliderPosZ.OnValueChangedAsObservable()
            .Subscribe(value =>
            {
                //値の整形
                var arrangeValue = Mathf.Floor((value - 0.5f) * 100) / 100 * POS_VALUE_RANGE_ABS;
                _textPosZ.text = SetPositionZ(arrangeValue).ToString();
            })
            .AddTo(this);
        
        //Y軸操作のSlider
        _sliderRotY.OnValueChangedAsObservable()
            .Subscribe(value =>
            {
                //値の整形
                var arrangeValue = Mathf.Floor((value - 0.5f) * 100) / 100 * ROT_VALUE_RANGE_ABS;
                _textRotY.text = SetRotationY(arrangeValue).ToString();
            })
            .AddTo(this);
    }
    
    /// <summary>
    /// 与えられたパラメータに応じてX軸方向に移動
    /// </summary>
    /// <param name="x">座標</param>
    private float SetPositionX(float x)
    {
        var currentPos = _plateauParentTransform.localPosition;
        var pos =  new Vector3(_starLocalPos.x,currentPos.y,currentPos.z); 
        pos.x += x;
        _plateauParentTransform.localPosition = pos;
        return pos.x;
    }

    /// <summary>
    /// 与えられたパラメータに応じてY軸方向に移動
    /// </summary>
    /// <param name="y">座標</param>
    private float SetPositionY(float y)
    {
        var currentPos = _plateauParentTransform.localPosition;
        var pos =  new Vector3(currentPos.x,_starLocalPos.y,currentPos.z); 
        pos.y += y;
        _plateauParentTransform.localPosition = pos;
        return pos.y;
    }

    /// <summary>
    /// 与えられたパラメータに応じてZ軸方向に移動
    /// </summary>
    /// <param name="z">座標</param>
    private float SetPositionZ(float z)
    {
        var currentPos = _plateauParentTransform.localPosition;
        var pos =  new Vector3(currentPos.x,currentPos.y,_starLocalPos.z); 
        pos.z += z;
        _plateauParentTransform.localPosition = pos;
        return pos.z;
    }
    
    /// <summary>
    /// 与えられたパラメータに応じてY軸方向に回転
    /// </summary>
    /// <param name="y">Y軸回転</param>
    private float SetRotationY(float y)
    {
        var eulerAngles = _starLocalEuler;
        eulerAngles.y += y;
        _plateauParentTransform.localEulerAngles = eulerAngles;
        return eulerAngles.y;
    }
}

④キャラクターを配置

シーン上にUnityちゃんを配置します。

デモ動画ではわかりにくい工夫ですが、実際はかなり傾けて配置しています。
近場に出す場合であれば、この傾きが迫力に大きな差をつけます。


⑤実機にて確認、調整

あとはビルドして実地にて確認を行います。ツールを利用して座標を細かく調整を行います。


Occulusionしつつ影を落とす

冒頭のデモ動画では、Unityちゃんがビルに影を落としていました。
この影の有無で実在感が大きく変わります。


仕組みとしては何も描画しないOcculusion用マテリアルを都市3Dモデルに設定し、
その都市3Dモデルに対して影を落としています。

下記が Occulusionしつつ影を落とすShaderです。

Shader "Custom/Occulusion"
{
    Properties
    {
        _ShadowIntensity ("Shadow Intensity", Range (0, 1)) = 0.6
    }


    SubShader
    {
        Pass
        {
            Tags
            {
                "Queue"="geometry-1"
                "RenderType"="opaque"
            }
            ColorMask 0
        }

        Pass
        {
            Tags
            {
                "RenderType"="Transparent"
                "Queue"="Transparent"
                "LightMode" = "ForwardBase"
            }
            Blend SrcAlpha OneMinusSrcAlpha
            Cull Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fwdbase

            #include "UnityCG.cginc"
            #include "AutoLight.cginc"

            uniform fixed4 _Color;
            uniform float _ShadowIntensity;

            struct v2f
            {
                float4 pos : SV_POSITION;
                LIGHTING_COORDS(0, 1)
            };

            v2f vert(appdata_base v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                TRANSFER_VERTEX_TO_FRAGMENT(o);

                return o;
            }

            fixed4 frag(v2f i) : COLOR
            {
                float attenuation = LIGHT_ATTENUATION(i);
                return fixed4(0, 0, 0, (1 - attenuation) * _ShadowIntensity);
            }
            ENDCG
        }
    }
}

ただし、このままだとレンダリングの都合上、SkyBoxより前面に描画されてしまうので
もう一つ、SkyBoxのみ描画するカメラを用意する必要があります。

これにより、何も描画しないOcculusion用オブジェクトに影を落とすことができます。


おわりに

PLATEAUのオープンデータ化により、クリエイターの可能性が広がったように思います。
今回のノウハウを開発中のCFAにも取り入れて、パワーアップさせていきたいところです。

Immersal以外のARクラウドとPLATEAUの組み合わせも引き続き検証していきます。


参考リンク

【Unity】影だけ映る地面を用意し、かつ地面の下が見えないようにする



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる