TECH SCAPE

AR関連多め(HoloLens, AzureKinect、SparkAR)

【Unity】float型配列をカラーマップに変換するComputeShader

とある数値データを可視化する際、以下のようなカラーマップで表現することがあります。最近は街中のモニター体温計などでよく見ますね。

What is Thermography? -

f:id:a_hancho:20201227011822p:plain
サーモグラフィ

気象庁 | 海面水温に関する診断表、データ 日別海面水温

f:id:a_hancho:20201227011949p:plain
海水温

Unityでこれをやりたい場面があったので、数値を画像に変えるコンピュートシェーダーをかいてみたのでメモです。

実装

ComputeShader

#pragma kernel GetColorMapTex

Texture2D<float4> _ColorMapTex; // カラーマップの色の参考テクスチャ
int _ColorMapTexWidth;
float _CutOffValue; // ある数値以下の値は透明にしたい場合に使用
int _ResolutionX; // ColorMapの画質(横)
int _ResolutionY; // ColorMapの画質(縦)
RWTexture2D<float4> _ResultTex;

StructuredBuffer<float> _ValueBuffer;

[numthreads(1, 1 ,1)]
void GetColorMapTex(uint2 id : SV_DispatchThreadID)
{
    int valueBufferId = id.x + id.y * _ResolutionX;
    float powerVal = _ValueBuffer[valueBufferId];

    uint2 uv = uint2(
        floor(_ColorMapTexWidth * powerVal),
        0
    );
    float4 powerColor = _ColorMapTex[uv];
    if (powerVal < _CutOffValue) powerColor.a = 0;

    _ResultTex[id] = powerColor;
}

CPU側(C#)

using System;
using Cysharp.Threading.Tasks;
using System.Runtime.InteropServices;
using UnityEngine;

[RequireComponent(typeof(MeshRenderer))]
public class PowerMapVisualizer : MonoBehaviour
{
    [SerializeField] private ComputeShader _shaderSource;
    [SerializeField] private Texture _colorMapTex;
    [SerializeField] private string _texPropertyName = "_MainTex";
    [SerializeField] private float _cutOffValue = 0.1f;
    [SerializeField] private int _resolutionX = 33;
    [SerializeField] private int _resolutionY = 33;
    [SerializeField] private float _intervalSec = 0.1f;

    private float[] _powerValueArr;
    private int _shaderKernelId;
    private ComputeShader _shader;
    private ComputeBuffer _shaderBuffer;
    private RenderTexture _resultRenderTex;
    private Material _material;

    private void Start()
    {
        int powerValueLength = _resolutionX * _resolutionY;
        _powerValueArr = new float[powerValueLength];

        _shader = Instantiate(_shaderSource);
        _shaderKernelId = _shader.FindKernel("GetPowerMapTex");
        _shaderBuffer = new ComputeBuffer(powerValueLength, Marshal.SizeOf(typeof(float)));

        _resultRenderTex =
            new RenderTexture(_resolutionX, _resolutionY, 0, RenderTextureFormat.ARGB32);
        _resultRenderTex.enableRandomWrite = true;
        _resultRenderTex.Create();

        _shader.SetTexture(_shaderKernelId, "_ColorMapTex", _colorMapTex);
        _shader.SetInt("_ColorMapTexWidth", _colorMapTex.width);
        _shader.SetFloat("_CutOffValue", _cutOffValue);
        _shader.SetInt("_ResolutionX", _resolutionX);
        _shader.SetInt("ResolutionY", _resolutionY);
        _shader.SetTexture(_shaderKernelId, "_ResultRWTex", _resultRenderTex);

        _material = GetComponent<MeshRenderer>().material;
        UpdatePowerMapLoop();
    }

    private async void UpdatePowerMapLoop()
    {
        while (true)
        {
            UpdateTexture();
            await UniTask.Delay(TimeSpan.FromSeconds(_intervalSec));
        }
    }

    private void UpdateTexture()
    {
        // 仮数値データ生成箇所
        for (int y = 0; y < _resolutionY; y++)
        {
            for (int x = 0; x < _resolutionX; x++)
            {
                int index = x + y * _resolutionX;
                float lengthX = x * 2f / _resolutionX - 1f;
                float lengthY = y * 2f / _resolutionY - 1f;
                Vector2 distFromCenter = new Vector2(lengthX, lengthY);
                float length = distFromCenter.magnitude;
                _powerValueArr[index] = Mathf.Abs(Mathf.Sin(length - Time.time));
            }
        }

        _shaderBuffer.SetData(_powerValueArr);

        _shader.SetBuffer(_shaderKernelId, "_ValueBuffer", _shaderBuffer);
        _shader.Dispatch(_shaderKernelId, _resolutionX, _resolutionY, 1);
        _material.SetTexture(_texPropertyName, _resultRenderTex);
    }

    private void OnDisable()
    {
        _shaderBuffer.Release();
    }
}

↑コメント入れた個所で、中心からぐわーって広がるような仮の値を突っ込んでいます。

f:id:a_hancho:20201227013712p:plain
Inspector

Unlit/Transparent のマテリアルをあてた正方形のPlaneにこのスクリプトをアタッチしてみます。 ColorMapTexにはカラーマップの元となる画像を入れます。自分はMATLAB(数値解析ソフト)で jet と呼ばれる画像を適用しました。

jp.mathworks.com

結果

f:id:a_hancho:20201227012722g:plain
左 : 33 x 33 右 : 100 x 100

これだけでは対して面白くないですが、AR等でこの画像をもとにいろいろとできそうな感じです。(時が来たらブログを書きます)

参考

コンピュートシェーダーについて、以下記事を参考にさせていただきました。

edom18.hateblo.jp

blog.yucchiy.com

www.hanachiru-blog.com

【Unity, ARFoundation】ARKitの空間情報記録機能「ARWorldMap」を使ってみた

f:id:a_hancho:20200928142238p:plain

ARKitには ARWorldMap という、取得した空間の特徴点情報を一つにまとめる機能があります。

developer.apple.com

ARWorldMapを外部データとして保存 / 読み込みすることで、以下のようなことが実現できるでしょう。

* 空間に対するユーザーの位置を算出
* コンテンツの基準点を常に一定の位置に設定(ARAnchorとの併用
* 保存した空間の形状を点群で表示し、俯瞰的に観測
...etc

今回はUnityおよび ARFoundation を通じて、この機能に関する開発を行ってみました。

最終的には ARAnchor と組み合わせることで、配置した3DオブジェクトをARWorldMap読み込み時に復元するアプリケーションを作成してみます。

目次

リポジトリ

github.com

公式サンプルをForkし、以下フォルダに2つデモを追加しました。

/Assets/_ExtraDemo

環境

  • 開発PC : Windows10 Pro
  • Unity : 2019.4.8
  • ビルドPC ; MacMini(2020) Catalina 10.15.6
  • バイス : iPad Pro(2020) iOS13.7

  • Unity Package

    • ARFoundation : 3.1.5
    • ARKitPlugin : 3.1.7

空間の特徴点情報 保存/読込

ARFoundationの公式サンプルに ARWorldMap に関するデモシーンがあります。 こちらが最もシンプルに、空間情報を保存 / 読込できるもののようです。先ずはこちらをビルドしてみました。

Sceneのパス : /Assets/Scenes/ARWorldMap.unity

実装

以下の1スクリプトで実装されています。

Assets/Scripts/ARWorldMapController.cs

上記スクリプトの中からARWorldMap保存/読み込みに関するメソッドを抜き出してみました。 ARKit独自機能なため、プラットフォームの #define ディレクティブ #if UNITY_IOS で囲ってあげないとAndroid時にエラーが出るので要注意です。

#if UNITY_IOS
    IEnumerator Save()
    {
        // ARKitSessionSubsystemをARSessionから取得
        var sessionSubsystem = (ARKitSessionSubsystem)m_ARSession.subsystem;
        if (sessionSubsystem == null)
        {
            Log("No session subsystem available. Could not save.");
            yield break;
        }

        // ARWorldMapRequestをARKitSessionSubsystemから取得
        var request = sessionSubsystem.GetARWorldMapAsync();
        
        // ARWorldMapRequestが終わるまでループ
        while (!request.status.IsDone())
            yield return null;

        // ARWorldMapRequestがエラー時
        if (request.status.IsError())
        {
            Log(string.Format("Session serialization failed with status {0}", request.status));
            yield break;
        }

        // ARWorldMapを取得しRequestを破棄
        var worldMap = request.GetWorldMap();
        request.Dispose();

        SaveAndDisposeWorldMap(worldMap);
    }
    
    void SaveAndDisposeWorldMap(ARWorldMap worldMap)
    {
        // ARWorldMapをNativeArray<Vector3>にシリアライズ
        Log("Serializing ARWorldMap to byte array...");
        var data = worldMap.Serialize(Allocator.Temp);
        Log(string.Format("ARWorldMap has {0} bytes.", data.Length));

        // ローカルに保存
        var file = File.Open(path, FileMode.Create);
        var writer = new BinaryWriter(file);
        writer.Write(data.ToArray());
        writer.Close();
        
        // 用済みのデータやARWroldMap破棄
        data.Dispose();
        worldMap.Dispose();
        Log(string.Format("ARWorldMap written to {0}", path));
    }

    IEnumerator Load()
    {
        var sessionSubsystem = (ARKitSessionSubsystem)m_ARSession.subsystem;
        if (sessionSubsystem == null)
        {
            Log("No session subsystem available. Could not load.");
            yield break;
        }

        // ファイルを読み込み、NativeArray<byte>生成
        var file = File.Open(path, FileMode.Open);
        if (file == null)
        {
            Log(string.Format("File {0} does not exist.", path));
            yield break;
        }

        Log(string.Format("Reading {0}...", path));

        int bytesPerFrame = 1024 * 10;
        var bytesRemaining = file.Length;
        var binaryReader = new BinaryReader(file);
        var allBytes = new List<byte>();
        while (bytesRemaining > 0)
        {
            var bytes = binaryReader.ReadBytes(bytesPerFrame);
            allBytes.AddRange(bytes);
            bytesRemaining -= bytesPerFrame;
            yield return null;
        }

        var data = new NativeArray<byte>(allBytes.Count, Allocator.Temp);
        data.CopyFrom(allBytes.ToArray());

        // NativeArray<byte>をARWorldMapにデシリアライズ
        Log(string.Format("Deserializing to ARWorldMap...", path));
        ARWorldMap worldMap;
        if (ARWorldMap.TryDeserialize(data, out worldMap))
        data.Dispose();

        if (worldMap.valid)
        {
            Log("Deserialized successfully.");
        }
        else
        {
            Debug.LogError("Data is not a valid ARWorldMap.");
            yield break;
        }

        // ARWorldMapをARKitSubsystemに適用
        Log("Apply ARWorldMap to current session.");
        sessionSubsystem.ApplyWorldMap(worldMap);
    }
#endif

結果

f:id:a_hancho:20200928144141g:plain

このようにスキャンし、セーブボタンを押すとARWorldMapが保存されます。

アプリを再起動しロードボタンを押してみると、20秒ほどで前回保存したARWorldMapが読み込まれます。

使用上のポイント

このデモSceneをビルドする上で注意点があったのでまとめます。

1. 対応バージョン要確認

使用したいUnityバージョンによって、サンプルのバージョンが変わります。 今回は記事投稿時点で安定してそうな 3.1 を選択しました。

latest-version以外を使用する場合は、gitブランチを切り替えましょう。

2. Packageに関するエラー

使用するUnityバージョンによって、以下のようなエラーが出ます。

f:id:a_hancho:20200918003126p:plain

エラーに該当するPackageのバージョンが Verify になっているか、PackageManagerで確認しましょう。

f:id:a_hancho:20200918003240p:plain

3. MappingStatusTextが表示されない

Textの参照がなぜか初期状態ではずれていました。 実機でログを確認したい方は、UITextにあてはめましょう。

f:id:a_hancho:20200918142916j:plain

4. 読込待機時間

保存は一瞬で終わるのに対して、読み込みは結構かかります。 だいたいですが、20畳くらいの部屋の空間マップデータが20~30秒かかりました。

コンテンツに盛り込む場合は、ローディングをつける等の工夫が必要そうですね。

 

ARAnchorとの併用

ドキュメントをみると、ARWorldMap は空間の特徴点情報と一緒に、 ARAnchor の情報も含んでくれるようです。 タップ位置にアンカーを作成する実装を加えて、検証してみました。

Sceneのパス : Assets/_ExtraDemo/Scenes/ARWorldMapWithAnchors.unity

実装

アンカーに関する実装は、以下のサンプルSceneがあります。

Assets/Scenes/AnchorPoints.unity

要するに、以下3つのコンポーネントARSessionOrigin に追加するとよさそうです。

f:id:a_hancho:20200926233602p:plain

Assets/Scripts/AnchorCreator.cs はタップ位置にARAnchorを設置するスクリプトです。

今回詳しい内容は割愛しますが、このままだとUIをすり抜けてAnchorが生成されてしまうため、Update内に以下スクリプトを追加してあげるとより良い感じです。

// uGUI操作時は無視
if (EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId)) {
     return;
}

結果

f:id:a_hancho:20200928144420g:plain

Anchorを乱れうちしてARWorldMapを保存。

f:id:a_hancho:20200928144304g:plain

読み込んだところ復元しました。

3Dモデルの配置復元

無事にARAnchorもARWorldMapに含まれて保存できていることを確認しました。 つまり、ある3DモデルとARAnchorとの位置関係を別途保存しておけば、そのモデルも同位置に復元することができそうです。

Sceneのパス : Assets/_ExtraDemo/Scenes/ARWorldMapWithAnchorAndOrnament.unity

実装

単体のARAnchor作成機能

まず、 ARAnchor は1つで十分なので、1つだけ作成するスクリプトを作ります。 これは AR Session Origin にアタッチして使用します。

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

[RequireComponent(typeof(ARAnchorManager))]
[RequireComponent(typeof(ARRaycastManager))]
public class SingleAnchorCreator : MonoBehaviour
{
    [SerializeField] private Transform _anchorTrackingTransform;
    
    private ARAnchor _anchor;
    private ARRaycastManager _raycastManager;
    private ARAnchorManager _anchorManager;
    private static List<ARRaycastHit> _raycastHits = new List<ARRaycastHit>();
    
    public ARAnchor Anchor => _anchor;

    void Awake()
    {
        _raycastManager = GetComponent<ARRaycastManager>();
        _anchorManager = GetComponent<ARAnchorManager>();

        _anchorManager.anchorsChanged += OnAnchorsChanged;
    }

    void Update()
    {
        // シングルタップ時以外は無視
        if (Input.touchCount != 1)
        {
            return;
        }

        // uGUI操作時は無視
        if (EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId))
        {
            return;
        }

        // タッチ開始時以外は無視
        Touch touch = Input.GetTouch(0);
        if (touch.phase != TouchPhase.Began)
        {
            return;
        }


        if (_raycastManager.Raycast(touch.position, _raycastHits, TrackableType.FeaturePoint))
        {
            // 古いAnchor削除
            RemoveAnchor();
            
            // 新規Anchor作成
            Pose hitPose = _raycastHits[0].pose;
            AddAnchor(hitPose);
        }
    }
    
    private void OnAnchorsChanged(ARAnchorsChangedEventArgs eventArgs)
    {
        if (eventArgs.added.Count > 0)
        {
            Transform targetTransform = eventArgs.added.Last().transform;
            Pose targetPose = new Pose(targetTransform.position, targetTransform.rotation);
            MoveTrackingObj(targetPose);
        }
    }
    
    public void AddAnchor(Pose hitPose)
    {
        _anchor = _anchorManager.AddAnchor(hitPose);
        if (_anchor == null)
        {
            Logger.Log("Error creating anchor");
        }
    }

    public void RemoveAnchor()
    {
        if (_anchor == null)
        {
            return;
        }
        
        _anchorManager.RemoveAnchor(_anchor);
        _anchor = null;
    }
    
    public void MoveTrackingObj(Pose pose)
    {
        _anchorTrackingTransform.SetPositionAndRotation(pose.position, pose.rotation);
    }
    
}

3Dモデル配置 / 保存 / 読み込み 機能

つぎに3Dオブジェクトです。以下機能を持つスクリプトを作成しました。 これも AR Session Origin に追加します。

* 2本指タップで3Dモデルの配置
* ARAnchorとの位置関係を保存 / 読み込みするメソッド
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

[RequireComponent(typeof(ARAnchorManager))]
[RequireComponent(typeof(ARRaycastManager))]
public class OrnamentCreator : MonoBehaviour
{
    [SerializeField] private GameObject _ornamentPrefab;

    private ARRaycastManager _arRaycastManager;
    private ARAnchorManager _arAnchorManager;
    private ARAnchor _latestARAnchor;
    private GameObject _latestAnchorTrackerObj;
    private GameObject _ornamentObj;

    private static List<ARRaycastHit> _raycastHits = new List<ARRaycastHit>();

    void Start()
    {
        _arRaycastManager = GetComponent<ARRaycastManager>();
        _arAnchorManager = GetComponent<ARAnchorManager>();

        _latestAnchorTrackerObj = new GameObject();
        _latestAnchorTrackerObj.name = "ARAnchorTracker";
        
        _arAnchorManager.anchorsChanged += OnARAnchorChanged;
    }

    void Update()
    {
        // 2本タップ以外はスルー
        if (Input.touchCount != 2)
        {
            return;
        }
        
        // uGUI操作時は無視
        if (EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId)) {
            return;
        }

        // タップ開始時以外はスルー
        Touch touch = Input.GetTouch(0);
        if (touch.phase != TouchPhase.Began)
        {
            return;
        }

        // タップ処理
        if (_arRaycastManager.Raycast(touch.position, _raycastHits, TrackableType.FeaturePoint))
        {
            Pose hitPose = _raycastHits[0].pose;
            UpdateOrnament(hitPose);
        }
    }

    /// <summary>
    /// 置物の作成・位置の更新
    /// </summary>
    /// <param name="pose">配置位置</param>
    /// <param name="isPoseLocal">poseがローカル座標のときはtrue</param>
    private void UpdateOrnament(Pose pose, bool isPoseLocal = false)
    {
        if (_ornamentObj == null)
        {
            _ornamentObj = Instantiate(_ornamentPrefab, _latestAnchorTrackerObj.transform);
        }

        if (isPoseLocal)
        {
            _ornamentObj.transform.localPosition = pose.position;
            _ornamentObj.transform.localRotation = pose.rotation;
        }
        else
        {
            _ornamentObj.transform.SetPositionAndRotation(pose.position, pose.rotation);
        }
    }

    /// <summary>
    /// アンカー更新時のイベント
    /// </summary>
    /// <param name="eventArgs">イベントの変数</param>
    private void OnARAnchorChanged(ARAnchorsChangedEventArgs eventArgs)
    {
        // Anchor追加時
        if (eventArgs.added.Count > 0)
        {
            // 最新ARAnchor更新
            _latestARAnchor = eventArgs.added.Last();

            // _arAnchorTrackerObj移動
            Transform arAnchorTransform = _latestARAnchor.transform;
            _latestAnchorTrackerObj.transform.SetPositionAndRotation(
                arAnchorTransform.position,
                arAnchorTransform.rotation
            );
        }

        // Anchor消滅時
        if (eventArgs.removed.Contains(_latestARAnchor))
        {
            _latestARAnchor = null;
        }
    }

    /// <summary>
    /// アンカーに対する置物の位置を保存
    /// </summary>
    public void SaveOrnamentPoseFromAnchor()
    {
        // Anchorや置物がないときはスルー
        bool isEmpty = _latestARAnchor == null || _ornamentObj == null;
        if (isEmpty)
        {
            Debug.LogError("ARAnchor is not found;");
            return;
        }

        // Anchorとの位置関係を保存
        Vector3 diffPos = _ornamentObj.transform.localPosition;
        Quaternion diffRot = _ornamentObj.transform.localRotation;
        PlayerPrefs.SetFloat("OrnamentPosX", diffPos.x);
        PlayerPrefs.SetFloat("OrnamentPosY", diffPos.y);
        PlayerPrefs.SetFloat("OrnamentPosZ", diffPos.z);
        PlayerPrefs.SetFloat("OrnamentRotX", diffRot.x);
        PlayerPrefs.SetFloat("OrnamentRotY", diffRot.y);
        PlayerPrefs.SetFloat("OrnamentRotZ", diffRot.z);
        PlayerPrefs.SetFloat("OrnamentRotW", diffRot.w);
    }

    /// <summary>
    /// アンカーに対する置物の位置を読み込んで移動
    /// </summary>
    public void LoadOrnamentPoseFromAnchor()
    {
        // Anchorとの位置関係を読み込み
        float posX = PlayerPrefs.GetFloat("OrnamentPosX");
        float posY = PlayerPrefs.GetFloat("OrnamentPosY");
        float posZ = PlayerPrefs.GetFloat("OrnamentPosZ");
        float rotX = PlayerPrefs.GetFloat("OrnamentRotX");
        float rotY = PlayerPrefs.GetFloat("OrnamentRotY");
        float rotZ = PlayerPrefs.GetFloat("OrnamentRotZ");
        float rotW = PlayerPrefs.GetFloat("OrnamentRotW");

        Pose diffPose = new Pose(
            new Vector3(posX, posY, posZ),
            new Quaternion(rotX, rotY, rotZ, rotW)
        );
        UpdateOrnament(diffPose, true);
    }
}

別途3Dモデル位置のSaveとLoadボタンを用意し、それぞれ SaveOrnamentPoseFromAnchorLoadOrnamentPoseFromAnchor が実行されるようにします。

ちなみに今回使用した3DモデルはAssetStoreでみつけた宝箱。

assetstore.unity.com

結果

f:id:a_hancho:20200928145159p:plain

このように配置して保存。

f:id:a_hancho:20200928145510g:plain

アプリ再起動後にARWorldMapと3Dモデル位置を読み込むと、宝箱を同じ位置に再配置することができました。

まとめ

iOS限定ではありますが、空間情報を保存することができました。

マルチプラットフォームで空間情報を保存・共有したい場合には AzureSpatialAnchor なども検討してみたほうがいいかもしれませんね。

Appendix

ARWorldMapの参考記事

developer.apple.com qiita.com qiita.com qiita.com shu223.hatenablog.com

iOSビルドうまくいかない問題

今回、新品のMacMiniでiOS13.7にビルドをしようとしたら、Permision denied エラーやら、XCodeのバージョン違いやらで結構つまづいてしまいました。 いつの時代もスッといくものではないですよね...(1年半前くらいにも天を仰いだ気がする)。

今回ビルドが通るまでに参考になった記事を載せておきます。 以下はすべての人に当てはまるわけではないので、うまくいかない人はめげずにエラー文で検索してみましょう。

winmemorandum.blogspot.com qiita.com

【SparkAR】耳に3Dモデルをつける

SparkARを勉強中です(10000フィルターつくるぞ!)

耳にピアス的なものをつける簡単なエフェクトをつくりたかったのですが、少しだけ工夫が必要だったのでメモしときます。

Gitリポジトリ

github.com

f:id:a_hancho:20200824175527g:plain

Libraryの 3D Objects から星をインポートして、耳に取り付けました。

FaceTargetTrakingの下にアクセサリーを二つ配置

f:id:a_hancho:20200824175753p:plain

faceTrakerの下に両耳に取り付けたい3Dモデルを2つ配置します。

f:id:a_hancho:20200824183129j:plain

すると鼻の位置に星が二つ来ます。

これらの 3D position を調整して両耳の位置にもってきてもそれっぽくなります。 しかし、顔の幅が人それぞれなため、すべての人の耳の位置にはなりませんでした。

ほほ骨基準で配置する

以下のようにノードを組むと、だいたい耳に配置できることがわかりました

f:id:a_hancho:20200824180400p:plain

  • FaceFinder -> FaceSelect : 顔を取得
  • Cheek(Left Cheekbone, Right Cheekbone) : ほほの位置を取得
  • Add : ほほ骨から耳の位置に微修正

補足 : HeadOccluderを追加

f:id:a_hancho:20200824183539p:plain

LibraryからHeadOccluderを追加することで、顔を傾けたときに耳のアクセサリーが隠れるようになるのでおススメです!

今回は以上です。

MRTKをインポートするとRiderのdllと衝突してエラーが出た話

最近HoloLens2を入手しました! 私事ですが、2年半ぶりにHoloLens開発をするのでとてもワクワクです。

意気揚々とMRTK(MixedRealityToolKit)をUnityにインポートしたところ、とあるエラーがでてしまいました。

検索しても同じ症状の方がいなかったので...誰かの役にたつかもしれないと思ってメモしておきます。

エラー内容

f:id:a_hancho:20200821185837p:plain

以下2つでINotifyCompletion が重複して定義されているような内容です。

① mscorlib
.netのアセンブリ

② Assets/Plugins/Editor/JetBrains/JetBrains.Rider.Unity.Editor.Plugin.Repacked.dll
JetBrainRider関連のdll。

環境

  • OS : Window10 Pro
  • Unity : 2019.4.8f、2019.2.11f
  • MRTK : v2.4.0
  • Rider : 2020.2.1 (下記URLからダウンロード)

Rider: JetBrainsのクロスプラットフォーム.NET IDE

解決法

Riderを一度アンインストールし、JetBrain ToolBox Appからインストールすると解決しました。 こうすることで、②Riderのdllがなくなります。

www.jetbrains.com

以下記事の通り、Unity2019.2以降は②のdllは必要ないようです。 今回はなぜかこのdllが生まれていた上に、消しても解決しなかったので、思い切って再インストールしてみたところうまくいきました。

kan-kikuchi.hatenablog.com

P.S. ほかに解決法を知っている方がいらっしゃったら教えていただけると嬉しいです。

【AzureKinect】【Unity】RGBカメラと深度センサの情報を画像として表示する

f:id:a_hancho:20200805201723p:plain

AzureKinectのRGBカメラと深度センサの情報は、最大30fpsの速さで取得することができます。 以下記事では、これらの情報からメッシュを生成しました。

a-hancho.hateblo.jp

今回は、各種情報をTexture2Dに変換して表示します。 AzureKinectをUnity上で開発する際に、基本となる実装になるでしょう。

基本実装

AzureKinectの起動や終了等、基盤となるスクリプトです。

using Microsoft.Azure.Kinect.Sensor;
using System.Threading.Tasks;
using UnityEngine;

public class KinectImageViewer : MonoBehaviour
{
    private Device _kinectDevice = null;

    private void Start()
    {
        Init();
        StartLoop();
    }

    private void OnDestroy()
    {
        _kinectDevice.StopCameras();  // Kinectの終了処理
    }

    private void Init()
    {
        _kinectDevice = Device.Open(0);
        _kinectDevice.StartCameras(new DeviceConfiguration
        {
            ColorFormat = ImageFormat.ColorBGRA32,
            ColorResolution = ColorResolution.R1080p,
            DepthMode = DepthMode.NFOV_2x2Binned,
            SynchronizedImagesOnly = true,
            CameraFPS = FPS.FPS30
        });  // Kinectの開始処理。設定はお好みで。
    }

    private async void StartLoop()
    {
        while (true)
        {
            using (Capture capture = await Task.Run(() => _kinectDevice.GetCapture()).ConfigureAwait(true))
            {
                // ここで画像の生成、更新処理を行う
            }
        }
    }

ループ部分のcapture に、今後の実装に必要なことが入っています。 Unity向けに改変して表示していきます。

なお、Unity上でAzureKinectを初めて使用する方は、以下記事を参考にセットアップしてください。 tks-yoshinaga.hatenablog.com

RGB画像の表示

capture. capture.Color から1ピクセルずつの色情報を表すBGRA配列を取得し、テクスチャーを生成します。

そのままの順番で表示すると、上下左右が反転される点に注意しましょう。 UnityEngine.UIMicrosoft.Azure.Kinect.Sensor のImageが被るのでちょっとだけ厄介...。

using Microsoft.Azure.Kinect.Sensor;
using System.Threading.Tasks;
using UnityEngine;

public class KinectImageViewer : MonoBehaviour
{
    [SerializeField] private UnityEngine.UI.RawImage _viewerRawImage = null;

    // --- 省略  ---

    private async void StartLoop()
    {
        while (true)
        {
            using (Capture capture = await Task.Run(() => _kinectDevice.GetCapture()).ConfigureAwait(true))
            {
                // 必要な情報を用意
                Image colorImage = capture.Color;
                int pixelWidth = colorImage.WidthPixels;
                int pixelHeight = colorImage.HeightPixels;
                BGRA[] bgraArr = colorImage.GetPixels<BGRA>().ToArray();
                Color32[] colorArr = new Color32[bgraArr.Length];

                // BGRA配列 => Color32配列
                for (int i = 0; i < colorArr.Length; i++)
                {
                    int index = colorArr.Length - 1 - i;
                    colorArr[i] = new Color32(
                        bgraArr[index].R,
                        bgraArr[index].G,
                        bgraArr[index].B,
                        bgraArr[index].A
                    );
                }

                // Texture2Dの作成
                Texture2D resultTex = new Texture2D(pixelWidth , pixelHeight );
                resultTex.SetPixels32(colorArr);
                resultTex.Apply();

                // RawImageの更新
                _viewerRawImage.texture = GetTexture2D(pixelWidth, pixelHeight, colorArr);
                _viewerRawImage.rectTransform.sizeDelta = new Vector2(width, height); // rectTransformのサイズ変更
        }
    }
}

その辺にあったガムやルアーを配置して撮影してみました

f:id:a_hancho:20200805190506g:plain
RGBカメラ映像

深度画像の表示

capture.Depth から震度情報のushort配列を取得し、テクスチャーを生成します。 深度の範囲を指定できるようにしておくと、いい感じに画像を調整できます。

using Microsoft.Azure.Kinect.Sensor;
using System.Threading.Tasks;
using UnityEngine;

public class KinectImageViewer : MonoBehaviour
{
    [SerializeField] private UnityEngine.UI.RawImage _viewerRawImage = null;
    [SerializeField] private int _depthDistanceMin = 200;
    [SerializeField] private int _depthDistanceMax = 3000;

    // --- 省略  ---

    private async void StartLoop()
    {
        while (true)
        {
            using (Capture capture = await Task.Run(() => _kinectDevice.GetCapture()).ConfigureAwait(true))
            {
                // 必要な情報を用意
                Image depthImage = capture.Depth;
                int pixelWidth = depthImage.WidthPixels;
                int pixelHeight = depthImage.HeightPixels;
                ushort[] depthByteArr = depthImage.GetPixels<ushort>().ToArray();
                Color32[] colorArr = new Color32[depthByteArr.Length];

                // ushort配列 => Color32配列
                for (int i = 0; i < colorArr.Length; i++)
                {
                    int index = colorArr.Length - 1 - i;

                    int depthVal = 255 - (255 * (depthByteArr[index] - _depthDistanceMin) / _depthDistanceMax);  // 近いほど値が大きくなるよう計算
                    if (depthVal < 0)
                    {
                        depthVal = 0;
                    }
                    else if (depthVal > 255)
                    {
                        depthVal = 255;
                    }

                    colorArr[i] = new Color32(
                        (byte)depthVal,
                        (byte)depthVal,
                        (byte)depthVal,
                        255
                    );
                }

                // Texture2Dの作成
                Texture2D resultTex = new Texture2D(pixelWidth , pixelHeight );
                resultTex.SetPixels32(colorArr);
                resultTex.Apply();

                // RawImageの更新
                _viewerRawImage.texture = GetTexture2D(pixelWidth, pixelHeight, colorArr);
                _viewerRawImage.rectTransform.sizeDelta = new Vector2(width, height); // rectTransformのサイズ変更
        }
    }
}

結果はこんな感じ。RGBとDepthでは画角が異なります。

f:id:a_hancho:20200805192200g:plain
深度画像

完成版スクリプト

f:id:a_hancho:20200805171751p:plain

Inspector上で以下を変更できるようにしてみました。

  • 表示する画像を選択(RGB or Depth)
  • 深度の範囲
using Microsoft.Azure.Kinect.Sensor;
using System.Threading.Tasks;
using UnityEngine;

public enum KinectImageType
{
    RGB = 0,
    Depth = 1,
}

public class KinectImageViewer : MonoBehaviour
{
    [SerializeField] private KinectImageType _imageType = KinectImageType.RGB;
    [SerializeField] private UnityEngine.UI.RawImage _viewerRawImage = null;
    [SerializeField] private int _depthDistanceMin = 500;
    [SerializeField] private int _depthDistanceMax = 5000;

    private Device _kinectDevice = null;

    private void Start()
    {
        InitKinect();
        StartLoop();
    }

    private void OnDestroy()
    {
        _kinectDevice.StopCameras();
    }

    private void InitKinect()
    {
        _kinectDevice = Device.Open(0);
        _kinectDevice.StartCameras(new DeviceConfiguration
        {
            ColorFormat = ImageFormat.ColorBGRA32,
            ColorResolution = ColorResolution.R1080p,
            DepthMode = DepthMode.NFOV_2x2Binned,
            SynchronizedImagesOnly = true,
            CameraFPS = FPS.FPS30
        });
    }

    private async void StartLoop()
    {
        while (true)
        {
            using (Capture capture = await Task.Run(() => _kinectDevice.GetCapture()).ConfigureAwait(true))
            {
                if (_imageType == 0)
                {
                    ViewColorImage(capture);
                }
                else
                {
                    ViewDepthImage(capture);
                }
            }
        }
    }

    // RGB情報をRawImageに表示
    private void ViewColorImage(Capture capture)
    {
        Image colorImage = capture.Color;
        int pixelWidth = colorImage.WidthPixels;
        int pixelHeight = colorImage.HeightPixels;

        BGRA[] bgraArr = colorImage.GetPixels<BGRA>().ToArray();
        Color32[] colorArr = new Color32[bgraArr.Length];

        for (int i = 0; i < colorArr.Length; i++)
        {
            int index = colorArr.Length - 1 - i;
            colorArr[i] = new Color32(
                bgraArr[index].R,
                bgraArr[index].G,
                bgraArr[index].B,
                bgraArr[index].A
            );
        }

        _viewerRawImage.texture = GetTexture2D(pixelWidth, pixelHeight, colorArr);
    }

    // 深度情報をRawImageに表示
    private void ViewDepthImage(Capture capture)
    {
        Image colorImage = capture.Depth;
        int pixelWidth = colorImage.WidthPixels;
        int pixelHeight = colorImage.HeightPixels;

        ushort[] depthByteArr = colorImage.GetPixels<ushort>().ToArray();
        Color32[] colorArr = new Color32[depthByteArr.Length];

        for (int i = 0; i < colorArr.Length; i++)
        {
            int index = colorArr.Length - 1 - i;

            int depthVal = 255 - (255 * (depthByteArr[index] - _depthDistanceMin) / _depthDistanceMax);
            if (depthVal < 0)
            {
                depthVal = 0;
            }
            else if (depthVal > 255)
            {
                depthVal = 255;
            }

            colorArr[i] = new Color32(
                (byte)depthVal,
                (byte)depthVal,
                (byte)depthVal,
                255
            );
        }

        _viewerRawImage.texture = GetTexture2D(pixelWidth, pixelHeight, colorArr);
    }

    // カラー配列 -> Texture2D
    private Texture2D GetTexture2D(int width, int height, Color32[] colorArr)
    {
        _viewerRawImage.rectTransform.sizeDelta = new Vector2(width, height);

        Texture2D resultTex = new Texture2D(width, height);
        resultTex.SetPixels32(colorArr);
        resultTex.Apply();
        return resultTex;
    }
}

参考記事

以下の公式サンプル github.com

Unityを使用せずに画像を表示している記事 tks-yoshinaga.hatenablog.com tks-yoshinaga.hatenablog.com

を参考にさせていただきました。

【AzureKinect】【Unity】点群からメッシュをリアルタイム生成する

f:id:a_hancho:20200721031530p:plain

まえがき

最近、AzureKinectを入手しました! 楽しいです。

本記事では、↓動画のようにリアルタイムで点群からメッシュ化する実装を紹介します。

目次

本編

点群を表示

まずはベースとなる点群を表示します。こちらの記事がとても参考になりました。

tks-yoshinaga.hatenablog.com

AzureKinectから取得できるColorImageとDepthImageから、それぞれ点の頂点カラーと座標を取得しています。

このようなものができます。

f:id:a_hancho:20200719193320g:plain

頂点からポリゴン生成

上記記事内のコードで、以下のような記述があります。

mesh.SetIndices(indices, MeshTopology.Points, 0);

こちらは、ポリゴンの形状と頂点を指定する関数です。点群を表す MeshTopology.Points の場合、indices(int[]またはList<int>) には 0 ~ 頂点の合計数 の数が順番に入っているとよさそうです。

こちらを MeshTopology.Triangles に変更し、indicesを三角形を紡いでいくように指定すると、面が成形できます。

https://cdn-ak.f.st-hatena.com/images/fotolife/s/soramamenatan/20190927/20190927152918.gif
> その30 気になる頂点インデックスの意義:より引用

そこで、以下のように変更してみます。

using Microsoft.Azure.Kinect.Sensor;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;

public class KinectScript : MonoBehaviour
{
    // --- 省略  ---

    private void InitMesh()
    {
        // --- 省略  ---

        List<int> indices = GetTriangleIndiceList();
        mesh.SetIndices(indices, MeshTopology.Triangles, 0);
        gameObject.GetComponent<MeshFilter>().mesh = mesh;
    }

    // triangleのindiceを取得する関数
    private List<int> GetTriangleIndiceList()
    {
        int width = kinect.GetCalibration().DepthCameraCalibration.ResolutionWidth;
        int height = kinect.GetCalibration().DepthCameraCalibration.ResolutionHeight;
        List<int> indicateList = new List<int>();

        for (int y = 0; y < height - 1; y++)
        {
            for (int x = 0; x < width - 1; x++)
            {
                int index = y * width + x;
                int a = index;
                int b = index + 1;
                int c = index + width;
                int d = index + width + 1;

                indicateList.Add(a);
                indicateList.Add(b);
                indicateList.Add(c);
                indicateList.Add(c);
                indicateList.Add(b);
                indicateList.Add(d);
            }
        }
        return indicateList;
    }
}

完成品がこちら

f:id:a_hancho:20200720150045p:plain

正面からみるとできているようですが、俯瞰で見ると大変なことに...。

positionが(0, 0, 0)の頂点を含む場合はポリゴン生成しない

失敗したメッシュをよく分析してみると、positionがVector3(0, 0, 0)の点が無数あって、そこから面が作られているのが原因でした。

ここで、DepthImageを確認してみましょう。 白に近いほどAzureKinectから近距離にあります。

f:id:a_hancho:20200721023546p:plain

人の周りなど、深度差が大きい箇所が真っ白になっています。この部分の頂点がVector3(0, 0, 0)になってしまうようです。

そこで、頂点のpositionのVector3.magnitudeが0の点を含んでいた場合はポリゴンの生成を行わないようにしてみます。

using Microsoft.Azure.Kinect.Sensor;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;

public class KinectScript : MonoBehaviour
{
    // --- 省略  ---

    private void InitMesh()
    {
        // --- 省略  ---

        // ここにあったmesh.SetIndicesは、loopしているスクリプトに移動

        gameObject.GetComponent<MeshFilter>().mesh = mesh;
    }

    private async Task KinectLoop()
    {
        while (true)
        {
            using (Capture capture = await Task.Run(() => kinect.GetCapture()).ConfigureAwait(true))
            {
                // --- 省略  ---

                mesh.vertices = vertices;
                mesh.colors32 = colors;
                mesh.RecalculateBounds();

                // indicesを更新
               int[] indices = GetIndices(vertices);
                mesh.SetIndices(indices, MeshTopology.Triangles, 0);
            }
        }
    }

    // triangleのindiceを取得する関数
    private int[] GetIndices(Vector3[] vertices)
    {
        int width = kinect.GetCalibration().DepthCameraCalibration.ResolutionWidth;
        int height = kinect.GetCalibration().DepthCameraCalibration.ResolutionHeight;
        List<int> indicateList = new List<int>();

        for (int y = 0; y < height - 1; y++)
        {
            for (int x = 0; x < width - 1; x++)
            {
                int index = y * width + x;
                int a = index;
                int b = index + 1;
                int c = index + width;
                int d = index + width + 1;

                bool isVaridA = vertices[a].magnitude != 0;
                bool isVaridB = vertices[b].magnitude != 0;
                bool isVaridC = vertices[c].magnitude != 0;
                bool isVaridD = vertices[d].magnitude != 0;

                if (isVaridA & isVaridB & isVaridC)
                {
                    indicateList.Add(a);
                    indicateList.Add(b);
                    indicateList.Add(c);
                }

                if (isVaridC & isVaridB & isVaridD)
                {
                    indicateList.Add(c);
                    indicateList.Add(b);
                    indicateList.Add(d);
                }
            }
        }
        return indicateList.ToArray();
    }
}

これで最初に乗せた動画のようにMesh化することができました。

完全版Script

TopologyをInspector上で選択できる機能を追加し、自己流に改造してみたコードがこちら。 コピペで動くはずです。

f:id:a_hancho:20200721011242p:plain

using Microsoft.Azure.Kinect.Sensor;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;

[RequireComponent(typeof(MeshFilter))]
public class KinectScript : MonoBehaviour
{
    [SerializeField] private MeshTopology _meshTopology = MeshTopology.Triangles;

    private Device _kinectDevice;
    private Transformation _kinectTransformation;

    private int _pointNum;
    private int _pointWidth;
    private int _pointHeight;

    private Mesh _mesh;
    private Vector3[] _meshVertices;
    private Color32[] _meshColors;

    private void Start()
    {
        InitKinect();
        InitMesh();
        Task task = KinectLoop();
    }

    private void OnDestroy()
    {
        _kinectDevice.StopCameras();
    }

    //Kinectの初期化
    private void InitKinect()
    {
        // kinect device
        _kinectDevice = Device.Open(0);
        _kinectDevice.StartCameras(new DeviceConfiguration
        {
            ColorFormat = ImageFormat.ColorBGRA32,
            ColorResolution = ColorResolution.R720p,
            DepthMode = DepthMode.NFOV_2x2Binned,
            SynchronizedImagesOnly = true,
            CameraFPS = FPS.FPS30
        });
        _kinectTransformation = _kinectDevice.GetCalibration().CreateTransformation();

        // point count
        _pointWidth = _kinectDevice.GetCalibration().DepthCameraCalibration.ResolutionWidth;
        _pointHeight = _kinectDevice.GetCalibration().DepthCameraCalibration.ResolutionHeight;
        _pointNum = _pointWidth * _pointHeight;
    }

    //Mesh情報の初期化
    private void InitMesh()
    {
        _mesh = new Mesh();
        _mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
        _meshVertices = new Vector3[_pointNum];
        _meshColors = new Color32[_pointNum];

        _mesh.vertices = _meshVertices;
        _mesh.colors32 = _meshColors;

        gameObject.GetComponent<MeshFilter>().mesh = _mesh;
    }

    //Kinectからデータを取得→描画
    private async Task KinectLoop()
    {
        while (true)
        {
            using (Capture capture = await Task.Run(() => _kinectDevice.GetCapture()).ConfigureAwait(true))
            {
                Image colorImage = _kinectTransformation.ColorImageToDepthCamera(capture);
                BGRA[] colorArray = colorImage.GetPixels<BGRA>().ToArray();

                Image xyzImage = _kinectTransformation.DepthImageToPointCloud(capture.Depth);
                Short3[] xyzArray = xyzImage.GetPixels<Short3>().ToArray();

                for (int i = 0; i < _pointNum; i++)
                {
                    _meshVertices[i].x = xyzArray[i].X * 0.001f;
                    _meshVertices[i].y = -xyzArray[i].Y * 0.001f;
                    _meshVertices[i].z = xyzArray[i].Z * 0.001f;

                    _meshColors[i].b = colorArray[i].B;
                    _meshColors[i].g = colorArray[i].G;
                    _meshColors[i].r = colorArray[i].R;
                    _meshColors[i].a = 255;
                }

                // update vertices and colors
                _mesh.vertices = _meshVertices;
                _mesh.colors32 = _meshColors;

                // update indices 
                List<int> indiceList = GetIndiceList(_meshVertices);
                _mesh.SetIndices(indiceList, _meshTopology, 0);

                _mesh.RecalculateBounds();
            }
        }
    }

    // meshのindicesを取得
    private List<int> GetIndiceList(Vector3[] vertices)
    {
        List<int> indiceList = new List<int>();

        if (_meshTopology == MeshTopology.Points)
        {
            for (int i = 0; i < _pointNum; i++)
            {
                bool isVaridPoint = vertices[i].magnitude != 0;
                if (isVaridPoint)
                {
                    indiceList.Add(i);
                }
            }
            return indiceList;
        }

        for (int y = 0; y < _pointHeight - 1; y++)
        {
            for (int x = 0; x < _pointWidth - 1; x++)
            {
                int index = y * _pointWidth + x;
                int a = index;
                int b = index + 1;
                int c = index + _pointWidth;
                int d = index + _pointWidth + 1;

                bool isVaridA = vertices[a].magnitude != 0;
                bool isVaridB = vertices[b].magnitude != 0;
                bool isVaridC = vertices[c].magnitude != 0;
                bool isVaridD = vertices[d].magnitude != 0;

                switch (_meshTopology)
                {
                    case MeshTopology.Triangles:
                        if (isVaridA & isVaridB & isVaridC)
                        {
                            indiceList.Add(a);
                            indiceList.Add(b);
                            indiceList.Add(c);
                        }
                        if (isVaridC & isVaridB & isVaridD)
                        {
                            indiceList.Add(c);
                            indiceList.Add(b);
                            indiceList.Add(d);
                        }
                        break;

                    case MeshTopology.Quads:
                        if (isVaridA && isVaridB && isVaridC && isVaridD)
                        {
                            indiceList.Add(a);
                            indiceList.Add(b);
                            indiceList.Add(d);
                            indiceList.Add(c);
                        }
                        break;

                    default:
                        if (isVaridA & isVaridB)
                        {
                            indiceList.Add(a);
                            indiceList.Add(b);
                        }
                        if (isVaridC & isVaridD)
                        {
                            indiceList.Add(c);
                            indiceList.Add(d);
                        }
                        break;
                }
            }
        }
        return indiceList;
    }
}

補足情報

リポジトリ

github.com

  • Assets/_Project/Scenes/_Mocks/KinectMeshSample.unity : 本記事と同様バージョン
  • Assets/_Project/Scenes/KinectMeshSimpleColorVertex.unity : Extenject(Zenject)、UniRxを使用したバージョン

AzureKlnectに関する個人用勉強場所です。参考までに。

他参考記事

qiita.com nn-hokuson.hatenablog.com

必要な画面(ローディング・エラー)まとめ

※ この記事は撤退予定のQiitaからの移植です。 ※ 2019年12月05日に投稿

背景

8thWallを用いてWebAR制作すると、メインコンテンツ以外に用意しなくてはいけない画面(ローディング、エラー、PC画面)がたくさんあります。 中には「iOSの特定のバージョンだけ出すエラー」といった限定的なものもあり、かなりややこしく感じました。

開発途中に「やっぱりこの画面も必要でした!」とならないように、まとめておきます。

公式サンプルのローディング・エラー画面

以下サンプルをみると、8thWallが用意してくれている画面が表示されます。 https://github.com/8thwall/web/tree/master/examples

これらはXRExtrasloadingmoduleruntimeerrormodule が表示してくれているものです。(開発の話は別記事で投稿予定) 本記事ではサンプルのスクリーンショットを交えつつ、オリジナルで画面を作る場合の注意点を書いていきます。

iOSAndroid 共通

ローディング

サンプルでは8thWallロゴが回転する画面が出ます。 ローディングが完了すると、フェードアウトしてカメラ映像が表示されます。

loading.jpg

オリジナルで制作する場合 全画面を覆うデザインであれば基本的に自由です。

ただし、商用公開時には「Powerd By 8thWall」のマークを入れる必要があるでしょう。 私が8thWall社に商用公開の連絡をした際にお願いされたことや、スパイダーバースARなど過去事例でも入っていることから確定事項かなと思います。

共通エラー

  • 起動後(onStart後)にエラーが起きた時
  • リアカメラのみ使用可能なSLAM(空間認識)をフロントカメラで使おうとした場合

に表示する画面です。主にお世話になるのは前者です。

cameraSelectionWorldTrackingError.jpg

オリジナルで制作する場合 「エラーが起きました」という内容とともに、リロードを促すようにしましょう。

userPromptErrpr

XR8(8thWallのAPI)プロンプトがユーザー側から拒否されている時に出るエラーのようです。 いろんな端末を使用しましたが、出たことのないんですよね...(自分だけでしょうか?)

userPromptError.jpg

オリジナルで制作する場合

リロードを促しましょう。正直、共通エラー画面と一緒のデザインにしてしまってもいいかなと思います。

Android限定

カメラ許可エラー

Chromeの設定で「サイトの設定 -> カメラ」という項目があります。 デフォルトでは「最初に確認する」になっていますが、ユーザーが意図的にOFFにしている(もしくはアドレスをBlockしている)とこのエラーが出ます。

cameraPermissionsErrorAndroid.jpg

オリジナルで制作する場合

  1. 右上メニューから設定を開く
  2. サイト設定を開く
  3. カメラを開く
  4. アクセス拒否を解除

という誘導をしましょう

iOS限定

カメラ許可エラー

先ほど書いたAndroidカメラエラーのiOSバージョンです。 「設定アプリ -> Safari」で「カメラとマイクのアクセス」がOFFになっていると、この画面が出ます(デフォルト設定ではON)。

cameraPermissionsErrorApple.jpg

オリジナルで制作する場合

Safari設定の変更を促す&リロードしてもらう内容にしましょう。

モーションセンサーエラー

これが一番厄介なエラー。iOS11以下、iOS12、iOS13でパターンが変わります

iOS11以下

motionPermissionsErrorApple.jpg

iOS11以下では、モーションセンサー機能はデフォルトでONです。 バージョンによっては、設定アプリにモーションセンサーON/OFFの項目がありませんでした。

オリジナルで制作する場合

「設定アプリ -> Safari」を見直してもらって、リロードしてもらいましょう。

iOS12

deviceMotionErrorApple.jpg

iOS12では、デフォルトでモーションセンサーが拒否されている問題があります。 参考 : iOS 12.2でWebVRとWebARが半ば終わった件について

ほとんどのユーザーが直面するエラーのためか、8thWallサンプルも詳細に設定手順が書かれています。

オリジナルで制作する場合

サンプル同様、細かく設定を促すことをお勧めします。

  1. 設定アプリを開く
  2. Safariを開く
  3. 「モーションと画面の向きのアクセス」をON
  4. リロードする

iOS13

ios13.jpg

iOS13では改善されて、モーションセンサーへのアクセスが初期状態で許可されています!

...と思いきや、「一度ユーザーの承諾を得る必要がある」という仕様が加わりました。ボタンを押す必要があり、結局用意する画面が1つ増えてしまいました。 サンプルでは、ローディング画面の上にモーダルが出て、「Continue」を押すとさらにモーションセンサー許可に進めます。

ちなみにこのモーダルだけ、XEExtrasではなくXR8で生成されている模様です。

参考 : iOS13でWebARとWebVRにおけるデバイスモーション設定が改善しました! iOS 12.2で半ば終わったWebVRとWebARがiOS 13でどうなったか

オリジナルで制作する場合

承諾ボタンを搭載したモーダルか画面を用意しましょう。 ボタンの挙動は上記記事を参考に、DeviceMotionEvent.requestPermission()DeviceOrientationEvent.requestPermission()を使用しましょう。

PC

スクリーンショット 2019-12-04 19.10.38.png

8thWallはPCでの使用ができません。 QRコードを載せつつ、スマートフォンでのアクセスを誘導する画面を用意しましょう