はじめに

これまではUnityでWebプラットフォームの呼称と言えば、“WebGL”と呼ばれることが多かったと思いますが、Unity 6からは、WebGPUも加わり混乱を避けるため、”Web”と呼ぶことを公式が推奨しています。この記事でも“Web”と呼んでいきます。
また、Unity 6からAndroid、iPhoneも正式にサポートされるようになったため、その辺りを中心に紹介していきます。

目次

開発環境

  • Unity 6 (v6000.023f1)
  • UniTask (v2.5.10)

新しいサポート内容

主に4つの新しいサポートがあります。

  • WebAssembly 2023
    • パフォーマンスの向上
  • LocationService
    • 位置情報(緯度経度)の取得
  • Compass API
    • 北の方角の取得
  • モバイル端末のサポート
    • 旧バージョンでも使用可能だったが、正式対応された機能

以下は公式ドキュメントから引用です。

Web platform (previously WebGL)

  • Implemented the following LocationService methods and properties in Web platform:
    • Start()
    • Stop()
    • isEnabledByUser
    • lastData
    • status
  • Added the ability to copy and paste to and from the Unity player.
  • Added support for WebAssembly 2023. For more information, refer to WebAssembly 2023. WebAssembly 2023 includes support for up to 4GB of heap memory and is a collection of the following WebAssembly language features:
    • WebAssembly native exceptions
    • WebAssembly Single Instruction Multiple Data (SIMD)
    • Optimized data operations
    • BigInt
    • WebAssembly.Table
    • Non-trapping float to int conversions
    • Sign extension
  • Implemented the following LocationService methods and properties:
    • GetLastHeading()
    • SetHeadingUpdatesEnabled()
    • IsHeadingUpdatesEnabled()
    • GetHeadingStatus()
    • IsHeadingAvailable()
  • Added support for the Compass API.
  • Added support for mobile browsers.
  • Implemented the Emscripten 3.1.38 toolchain.

WebXRについて気になる方もいると思いますが、特に記載がないため、今でもWebXR Exportを利用する必要があるかと思います。
ARFoundationが動くか試しましたが、動きませんでした。

VFX Graph 対応

公式ドキュメントに記載は見当たらないものの、VFX Graphも動かせるようになりました。
サンプル集から各種サンプルをモバイル端末で開くと、動かせるのが確認できると思います。

”あれ”が動くようになった

UnityユーザーならなじみのUnityChanですが、約4年前のこの記事の頃にはモバイルでは動かすことができませんでしたが、Unity 6 では動きました。

Pixel6で撮影

お手持ちのスマートフォンで試したい方は下記URLからどうぞ。

RenderPipelineの設定

自分で動かして試したいという方はRenderPipelineの設定に注意してください。UnityChanSSUに内蔵されているUTS2URPPipelineAssetはWebでは動作しませんでした。Unity 6 から標準で用意されているMobile_RPAssetを使用する必要があります。さらに、Mobile_RendererのRenderingPathがデフォルトではForward+になっているため、Forwardにする必要があります。
UnityChanのShaderがどうやら、Forward+にはまだ対応していないみたいです。

周辺スポット表示アプリ

Webで対応している以下の機能を用いて、緯度経度から周辺スポットを表示するWebアプリを作成してみました。会社周辺のスポットをあらかじめJsonとして設定して表示しています。

パーミッション

カメラ、位置情報、ジャイロ(iOSのみ) を扱うには、パーミッション許可が必要になります。
ブラウザ側の設定で許可されていない場合は動作しません。

Unity側のソースコードで呼び出す時は、それぞれ次のタイミングでパーミッションのダイアログが表示されます。

//Webカメラ
Application.RequestUserAuthorization(UserAuthorization.WebCam);

//位置情報
Input.location.Start();

//ジャイロセンサー(iOSのみ)
Input.gyro.enabled = true;

パーミッションのダイアログが表示される時は、アプリからフォーカスが外れるため、
入力されるまで待機したい場合、次のようなコードを用意しておくと便利だと思います。

/// <summary>
/// フォーカスがアプリにあるか?
/// </summary>
private bool _hasFocus;

/// <summary>
/// フォーカスがアプリに当たるまで待機
/// </summary>
protected async UniTask waitFocusAsync()
{
    await UniTask.Delay(100);
    await UniTask.WaitUntil(() => _hasFocus);
}

/// <summary>
/// OnApplicationFocus
/// </summary>
/// <param name="hasFocus">アプリケーションにフォーカスがあるときTrue</param>
private void OnApplicationFocus(bool hasFocus)
{
    _hasFocus = hasFocus;
}

iOSでジャイロセンサーの許可を取る場合は、特別な対応が必要で、直前に画面を触れさせる必要があります。UnityのuGUIのボタン押下で触れさせるでも大丈夫です。

ARアプリ風に実装

Webカメラとジャイロセンサーを用いて、3DoFのARアプリ風にします。

1.カメラの映像を表示するためにRawImageを用意

  • Canvasを以下のように設定して、アプリの背景になるようにします。
    • Render Mode : Screen Space – Camera
    • Render Camera : Main Camera
    • Plane Distance : 990 (MainCameraのFarClipより少し小さい値)

2.Webカメラの取得

以下はWebカメラを取得するソースコードです。カメラのテクスチャを取得できたら先ほどのRawImageに設定してください。WebCamDevice[]はAndroid、iOSともに配列の要素の1からバックカメラが取得できます。

/// <summary>
/// カメラプレビュー
/// </summary>
private RawImage _cameraPreview;

/// <summary>
/// Webカメラ
/// </summary>
private WebCamTexture _webCamTexture;

/// <summary>
/// カメラデバイス
/// </summary>
private WebCamDevice[] devices;

/// <summary>
/// Webカメラの設定
/// </summary>
private void setWebCamera()
{
    devices = WebCamTexture.devices;
    if (devices.Length > 0)
    {
        if (_cameraPreview == null)
        {
            _cameraPreview = GetComponent<RawImage>();
        }
        
        // "0"Front, "1"Back
        var deviceName = devices[1].name;
        _webCamTexture = new WebCamTexture(deviceName);
        _cameraPreview.color = Color.white;
        _cameraPreview.texture = _webCamTexture;
        _webCamTexture.Play();
    }
}

3.ジャイロセンサーで3Dofにする

ジャイロセンサーからスマートフォンの傾きを取得し、Unity上のMain Cameraに反映することで3DoFにしています。以下は傾きの取得方法のサンプルですが、縦持ちにしか対応していないので気をつけてください。

/// <summary>
/// Androidかどうか?
/// </summary>
/// <returns></returns>
[DllImport("__Internal")]
private static extern int IsAndroid();

/// <summary>
/// 右手系からUnityの左手系に変更
/// </summary>
/// <param name="q"></param>
/// <returns></returns>
private Quaternion gyroToUnity(Quaternion q)
{
    return new Quaternion(q.x, q.y, -q.z, -q.w);
}

/// <summary>
/// カメラの向きを更新
/// </summary>
void updateCameraRotate()
{
    if(!Input.gyro.enabled) return;

    //Androidかどうか?
    _isAndroid = IsAndroid() == 1;
    //ジャイロセンサーから傾きの取得
    var correctedPhoneOrientation = gyroToUnity(Input.gyro.attitude);
    //縦軸
    var verticalRotationCorrection = Quaternion.AngleAxis(-90, Vector3.left);
    //横軸
    var horizontalRotationCorrection = Quaternion.AngleAxis(_isAndroid ? -90 : 0, Vector3.up);
    //向きを乗算してMainCameraに反映
    var inGameOrientation = horizontalRotationCorrection * verticalRotationCorrection * correctedPhoneOrientation;
    transform.rotation = Quaternion.Slerp(transform.rotation, inGameOrientation, 0.2f);
}

IsAndroid()ですが、WebではUnity側でプラットフォームの判定が取得できないため、ネイティブプラグインを実装しています。以下のコードを.jslibファイルとして保存して、Assetsディレクトリ以下の任意の場所に配置すると動かせるかと思います。

mergeInto(LibraryManager.library, {
    IsAndroid: function () {
      var ua = window.navigator.userAgent.toLowerCase();
      if(ua.indexOf("android") !== -1){
        return 1;  
      }else{
        return 0;
      }
    }
});

緯度経度からスポットの座標を割り出す

自身の緯度経度とスポットの緯度経度、北の方角を用いて、スポットをUnity座標に変換します。

1.CompassAPIから北の方角の取得

以下のようなコードで北の方角を取得することができます。

private void Start()
{
    // コンパスを有効化
    Input.compass.enabled = true;
}

/// <summary>
/// 現在の向きの取得(地理的な北極に対する度数で表した進行方向を取得)
/// </summary>
/// <returns></returns>
public float GetCurrentDirection()
{
    //デフォルトだと向きが反対に回転するため、逆にする
    return 360f - Input.compass.trueHeading;
}

北を向く用のGameObjectを作成し、そのGameObjectに先ほどのCompassAPIの北の方角を設定します。
北の方角(角度)は端末を動かせば変わるため、その差分をカメラの向きを足して補います。

[Header("北を向く座標系の中心")] 
[SerializeField]
private Transform _northT;

/// <summary>
/// 北の方角の更新
/// </summary>
private void updateNorthTransform()
{
    //カメラの向き
    var cameraEuler = Camera.main.transform.eulerAngles;
    //北の方角
    var northDirection = _geoSensor.GetCurrentDirection();
    //北の方角の反映
    var direction = cameraEuler.y + northDirection;
    _northT.eulerAngles = new Vector3(0,direction,0);
}

スポットを生成する時は、北を向くGameObject(_northT)を親オブジェクトとします。

2.二点間の緯度経度からUnity座標に変換する

自身の緯度経度を基準とし、対象スポットの緯度経度との差を求めて、地球の半径から距離(m)に変換しています。このやり方で大まかな位置は取得できますが、より高精度な座標を求めたい方は、緯度経度の座標によって1度あたりの距離が変わるため、そこを考慮する必要があります。興味がある人はこの方のサイトが上手くまとめられていて参考になると思います。

/// <summary>
/// ジオコーディングのユーティリティ
/// </summary>
public class GeocodeUtility
{
  /// <summary>
    /// 位置情報をUnity座標系に変換(距離のみ)
    /// </summary>
    /// <param name="baseLat">基準の緯度</param>
    /// <param name="baseLon">基準の経度</param>
    /// <param name="targetLat">対象の緯度</param>
    /// <param name="targetLon">対象の経度</param>
    /// <returns></returns>
    public static Vector3 LocationToUnityPosition(double baseLat, double baseLon, double targetLat, double targetLon)
    {
        var diffLat = targetLat - baseLat;
        var diffLon = targetLon - baseLon;
        var diffLatM = LatitudeToMeters(diffLat);
        var diffLonM = LongitudeToMeters(diffLon, targetLat);
        var x = (float)diffLonM;
        var y = (float)diffLatM;

        return new Vector3(x,0f,y);
    }

    /// <summary>
    /// 緯度をメートル単位に変換
    /// </summary>
    /// <param name="latitude">緯度</param>
    /// <returns>メートル単位の距離</returns>
    public static double LatitudeToMeters(double latitude)
    {
        //地球の半径(単位: m)
        const double earthRadius = 6371000; 
        
        double latitudeRadians = Math.PI * latitude / 180.0;
        double distance = earthRadius * latitudeRadians;

        return distance;
    }
    
    /// <summary>
    /// 経度をメートル単位に変換
    /// </summary>
    /// <param name="longitude">経度</param>
    /// <param name="latitude">緯度</param>
    /// <returns>メートル単位の距離</returns>
    public static double LongitudeToMeters(double longitude, double latitude)
    {
        //地球の半径(単位: m)
        const double earthRadius = 6371000;
        
        double longitudeRadians = Math.PI * longitude / 180.0;
        double latitudeRadians = Math.PI * latitude / 180.0;
        double distance = earthRadius * Math.Cos(latitudeRadians) * longitudeRadians;

        return distance;
    }
    
}

上記関数のLocationToUnityPosition()を利用して、スポットを生成すると以下のようになります。
スポットの緯度経度はあらかじめGoogleMapから取得してきたものを設定しています。

private void CreateSpot()
{
    //北の方角を向くGameObjectを親にしてスポットを生成
    var spotObject = Instantiate(_spotPrefab, _northT);
    var spotInfo = spotObject.GetComponent<SpotInfo>();
    //現在地の取得
    var locationLastData = Input.location.lastData;
    var currentPos = new double3(locationLastData.latitude, 
                                 locationLastData.longitude,
                                 locationLastData.altitude);
    //現在地とスポットの座標からUnity座標を計算
    var unityPosition = GeocodeUtility.LocationToUnityPosition(currentPos.x,
                                                               currentPos.y,
                                                               spotInfo.Latitude,
                                                               spotInfo.Longitude);
    //スポットのローカル座標を更新
    spotInfo.transform.localPosition = unityPosition;
}

センサー類の全ソースコード

基底クラス

/// <summary>
/// センサー類の基底クラス
/// </summary>
public abstract class BaseSensor : MonoBehaviour
{
    /// <summary>
    /// フォーカスがアプリにあるか?
    /// </summary>
    private bool _hasFocus;

    /// <summary>
    /// センサー起動
    /// </summary>
    public abstract UniTask RunAsync();

    /// <summary>
    /// センサー停止
    /// </summary>
    public abstract void Stop();
    
    /// <summary>
    /// フォーカスがアプリに当たるのを待機
    /// </summary>
    protected async UniTask waitFocusAsync()
    {
        await UniTask.Delay(100);
        await UniTask.WaitUntil(() => _hasFocus);
    }

    /// <summary>
    /// OnApplicationFocus
    /// </summary>
    /// <param name="hasFocus">アプリケーションにフォーカスがあるときTrue</param>
    private void OnApplicationFocus(bool hasFocus)
    {
        _hasFocus = hasFocus;
    }
    
}//BaseSensor End

Webカメラ

/// <summary>
/// カメラセンサー(Webカメラ)
/// </summary>
[RequireComponent(typeof(RawImage))]
public class CameraSensor : BaseSensor
{
    /// <summary>
    /// カメラプレビュー
    /// </summary>
    private RawImage _cameraPreview;

    /// <summary>
    /// Webカメラ
    /// </summary>
    private WebCamTexture _webCamTexture;

    /// <summary>
    /// カメラデバイス
    /// </summary>
    private WebCamDevice[] devices;
    
    /// <summary>
    /// カメラの許可を取得しているか?
    /// </summary>
    private bool isAuthorization => Application.HasUserAuthorization(UserAuthorization.WebCam);

    /// <summary>
    /// 起動
    /// </summary>
    public override async UniTask RunAsync()
    {
        Debug.Log("WebCamera Run");
        if (isAuthorization)
        {
            setWebCamera();
            return;
        }
        
        await Application.RequestUserAuthorization(UserAuthorization.WebCam);
        await waitFocusAsync();
        if (isAuthorization)
        {
            setWebCamera();
        }
    }

    /// <summary>
    /// 停止
    /// </summary>
    public override void Stop()
    {
        _webCamTexture = null;
        if(_cameraPreview != null) _cameraPreview.color = Color.black;
    }
    
    /// <summary>
    /// Webカメラの設定
    /// </summary>
    private void setWebCamera()
    {
        Debug.Log("WebCamera Found");
        devices = WebCamTexture.devices;
        for (int cameraIndex = 0; cameraIndex < devices.Length; ++cameraIndex)
        {
            Debug.Log($"Camera Index `{cameraIndex}`");
            Debug.Log($"Name: {devices[cameraIndex].name}");
            Debug.Log($"IsFrontFacing: {devices[cameraIndex].isFrontFacing}");
            Debug.Log($"Kind: {devices[cameraIndex].kind}");
        }
        if (devices.Length > 0)
        {
            if (_cameraPreview == null)
            {
                _cameraPreview = GetComponent<RawImage>();
            }
            
            // "0"Front, "1"Back
            var deviceName = devices[1].name;
            _webCamTexture = new WebCamTexture(deviceName);
            _cameraPreview.color = Color.white;
            _cameraPreview.texture = _webCamTexture;
            _webCamTexture.Play();
        }
    }
}//CameraSensor End

ジャイロ

/// <summary>
/// ジャイロセンサー
/// </summary>
[RequireComponent(typeof(Camera))]
public class GyroSensor : BaseSensor
{
    /// <summary>
    /// Androidかどうか?
    /// </summary>
    /// <returns></returns>
    [DllImport("__Internal")]
    private static extern int IsAndroid();
    
    private float slerpValue = 0.2f;
    private int verticalOffsetAngle = -90;
    private int horizontalOffsetAngle = 0;

    private bool _isAndroid;
    
    void Update()
    {
        updateCameraRotate();
    }
    
    /// <summary>
    /// 起動
    /// </summary>
    public override async UniTask RunAsync()
    {
        Debug.Log("GyroSensor Run");
        
        //ジャイロセンサーの有効化
        //iOSのみ、この際にパーミッションのダイアログが表示される
        //画面を一度ボタン等でタップさせる必要がある
        Input.gyro.enabled = true;
        await waitFocusAsync();
        await UniTask.WaitUntil(() => Input.gyro.enabled);
        
        //Androidかどうか?
        _isAndroid = IsAndroid() == 1;
        Debug.Log($"IsAndroid: {_isAndroid}");
        //水平軸をAndroidの時は-90 , iOSの時は0
        horizontalOffsetAngle = _isAndroid ? -90 : 0;
    }

    /// <summary>
    /// 停止
    /// </summary>
    public override void Stop()
    {
        Input.gyro.enabled = false;
    }
    
    /// <summary>
    /// カメラの向きを取得
    /// </summary>
    /// <returns></returns>
    public Vector3 GetCameraEulerAngles()
    {
        return transform.eulerAngles;
    }
    
    /// <summary>
    /// 右手系からUnityの左手系に変更
    /// </summary>
    /// <param name="q"></param>
    /// <returns></returns>
    private Quaternion gyroToUnity(Quaternion q)
    {
        return new Quaternion(q.x, q.y, -q.z, -q.w);
    }

    /// <summary>
    /// カメラの向きを更新
    /// </summary>
    void updateCameraRotate()
    {
        if(!Input.gyro.enabled) return;
        var correctedPhoneOrientation = gyroToUnity(Input.gyro.attitude);
        var verticalRotationCorrection = Quaternion.AngleAxis(verticalOffsetAngle, Vector3.left);
        var horizontalRotationCorrection = Quaternion.AngleAxis(horizontalOffsetAngle, Vector3.up);
        var inGameOrientation = horizontalRotationCorrection * verticalRotationCorrection * correctedPhoneOrientation;
        transform.rotation = Quaternion.Slerp(transform.rotation, inGameOrientation, slerpValue);
    }
    
}//GyroSensor End

地理情報

/// <summary>
/// 地理情報の取得センサー
/// </summary>
public class GeoSensor : BaseSensor
{
    /// <summary>
    /// 位置情報の精度(メートル単位)  
    /// </summary>
    /// <remarks>
    /// 使用するサービス精度(メートル単位)。これにより、デバイスの最後の位置座標の精度が決まります。
    /// 500 などの高い値を指定すると、デバイスは GPS チップを使用する必要がなくなり、バッテリー電力を節約できます。
    /// 5 ~ 10 などの低い値を指定すると、最高の精度が得られますが、GPS チップが必要になるため、バッテリー電力の消費量が増えます。
    /// デフォルト値は 10 メートルです
    /// </remarks>
    [SerializeField,Header("位置情報の精度(メートル単位)")] 
    private float _desiredAccuracyInMeters = 10f;

    /// <summary>
    /// 位置情報の更新単位(メートル単位)
    /// </summary>
    /// <remarks>
    /// Unity がInput.location を更新する前にデバイスが横方向に移動する必要がある最小距離 (メートル単位) 。
    /// 500 などの高い値を指定すると、更新が少なくなり、処理に必要なリソースが少なくなります。
    /// デフォルトは 10 メートルです
    /// </remarks>>
    [SerializeField,Header("位置情報の更新単位(メートル単位)")]
    private float _updateDistanceInMeters = 10f;

    /// <summary>
    /// 位置情報取得開始済みか?
    /// </summary>
    public bool IsRunning => Input.location.status == LocationServiceStatus.Running;
    
    /// <summary>
    /// 起動
    /// </summary>
    public override async UniTask RunAsync()
    {
        Debug.Log("GeoSensor Run");
        // コンパスを有効化
        Input.compass.enabled = true;
        
        // ロケーションの取得を開始
        Input.location.Start(_desiredAccuracyInMeters,_updateDistanceInMeters);
        await waitFocusAsync();
        await UniTask.WaitUntil(() => IsRunning);
    }

    /// <summary>
    /// 停止
    /// </summary>
    public override void Stop()
    {
        Input.compass.enabled = false;
        Input.location.Stop();
    }
    
    /// <summary>
    /// 現在の地理座標の取得
    /// </summary>
    /// <returns></returns>
    public double3 GetCurrentLocation()
    {
        if (!IsRunning)
        {
            Debug.LogError("センサーを起動してください。");
            return double3.zero;
        }

        var locationLastData = Input.location.lastData;
        return new double3(locationLastData.latitude, 
                           locationLastData.longitude,
                           locationLastData.altitude);
    }
    
    /// <summary>
    /// 現在の向きの取得(地理的な北極に対する度数で表した進行方向を取得)
    /// </summary>
    /// <returns></returns>
    public float GetCurrentDirection()
    {
        //デフォルトだと向きが反対に回転するため、逆にする
        return 360f - Input.compass.trueHeading;
    }
    
}//GeoSensor End

まとめ

WebAssembly 2023とVFX Graphの対応によって表現の幅が大きく広がったと思います。
地理情報が取得できるようになったことで、周辺スポット表示アプリのようにジオコーディングも可能になりました。GoogleのPlaceAPI等と組み合わせると、面白いことができるかもしれませんね。



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