UnityでAR FoundationのFace TrackingとVFX Graphを組み合わせて顔の周りにパーティクルを出す

UnityでAR FoundationのFace TrackingとVFX Graphを組み合わせて、以下のように顔の周りにパーティクルを出してみます。

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">AR Foundation Face Tracking + VFX Graph <a href="https://twitter.com/hashtag/madewithunity?src=hash&amp;ref_src=twsrc%5Etfw">#madewithunity</a> <a href="https://t.co/fX1ZdmCpBE">pic.twitter.com/fX1ZdmCpBE</a></p>&mdash; aadebdeb / Atsushi Asakura (@aa_debdeb) <a href="https://twitter.com/aa_debdeb/status/1258316919746162689?ref_src=twsrc%5Etfw">May 7, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

使用したUnityやパッケージのバージョン、実行端末は以下のようになっています。

- Unity: 2019.3.11f Personal
- AR Foundation: preview.8 - 3.1.0
- ARKit XR Plugion: preview.8 - 3.1.0
- ARKit Face Tracking: preview.8 - 3.1.0
- Visual Effect Graph: 7.3.1
- 機種: iPad Pro (11インチ) (第2世代)
- OS: iPadOS 13.4

ビルトインレンダリングパイプラインを使用して、リニア色空間に設定しています。

---

AR FoundationでFace Trackingをするには`ARFaceManager`コンポーネントを`AR Session Origin`オブジェクトに追加します。`ARFaceManager`コンポーネントは顔を検出すると`Face Prefab`フィールドに設定したオブジェクトをインスタンス化します。`Face Prefab`フィールドに設定するオブジェクトに`ARFace`コンポーネントを追加しておくと、そこから顔の頂点情報を取得することができます。この頂点情報とその他諸々をVFX Graphに渡すことで顔の周りからパーティクルを出せそうです。

ということで、以下のスクリプトを`ARFace`コンポーネントを持つオブジェクトに追加します。このスクリプトでは初期化時に`Visual Effect`コンポーネントを持つオブジェクトをインスタンス化します。その`Visual Effect`コンポーネントに`ARFace`やカメラ画像から取得した顔の頂点位置や法線、色の情報をAttribute Mapとして渡します。Attribute MapはFace Trackingの状態に応じて随時更新します。

```cs:FaceVFXManager.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;
using UnityEngine.VFX;

[RequireComponent(typeof(ARFace))]
public class FaceVFXManager : MonoBehaviour
{

    [SerializeField] VisualEffect m_VfxPrefab;
    [SerializeField] int m_MaxPoints = 4096;
    [SerializeField] int m_PointPerArea = 100;

    ARFace m_Face;
    VisualEffect m_VfxInstance;
    Camera m_Camera;
    ARCameraManager m_CameraManager;
    Texture2D m_PositionMap, m_NormalMap, m_ColorMap, m_CameraFeedTexture;

    void Awake()
    {
        m_Face = GetComponent<ARFace>();
        m_Camera = (Camera)FindObjectOfType(typeof(Camera));
        m_CameraManager = (ARCameraManager)FindObjectOfType(typeof(ARCameraManager));
    }

    void OnEnable()
    {
        m_Face.updated += OnUpdated;
        m_CameraManager.frameReceived += OnCameraFrameReceived;
        m_VfxInstance = Instantiate(m_VfxPrefab, transform.parent);
    }

    void OnDisable()
    {
        m_Face.updated -= OnUpdated;
        m_CameraManager.frameReceived -= OnCameraFrameReceived;
        Destroy(m_VfxInstance);
    }

    void OnUpdated(ARFaceUpdatedEventArgs eventArgs)
    {

        var tracking = m_Face.trackingState == TrackingState.Tracking;
        m_VfxInstance.SetBool("Tracking", tracking);

        if (tracking && m_Face.indices.IsCreated && m_Face.vertices.IsCreated)
        {
            m_VfxInstance.SetVector3("Transform_position", transform.position);
            m_VfxInstance.SetVector3("Transform_angles", transform.eulerAngles);
            m_VfxInstance.SetVector3("Transform_scale", transform.lossyScale);
            UpdateVfxAttributeMaps();
        }
    }

    void CreateVfxAttributeMaps(int pointNum)
    {
        var texSize = Mathf.CeilToInt(Mathf.Sqrt(pointNum));

        m_PositionMap = new Texture2D(texSize, texSize, TextureFormat.RGBAFloat, false);
        m_PositionMap.filterMode = FilterMode.Point;
        m_PositionMap.wrapMode = TextureWrapMode.Clamp;
        m_NormalMap = new Texture2D(texSize, texSize, TextureFormat.RGBAFloat, false);
        m_NormalMap.filterMode = FilterMode.Point;
        m_NormalMap.wrapMode = TextureWrapMode.Clamp;
        m_ColorMap = new Texture2D(texSize, texSize, TextureFormat.RGBAFloat, false);
        m_ColorMap.filterMode = FilterMode.Point;
        m_ColorMap.wrapMode = TextureWrapMode.Clamp;

        m_VfxInstance.SetTexture("Position Map", m_PositionMap);
        m_VfxInstance.SetTexture("Normal Map", m_NormalMap);
        m_VfxInstance.SetTexture("Color Map", m_ColorMap);
    }

    void UpdateVfxAttributeMaps()
    {
        var mesh = new Mesh();
        mesh.SetVertices(m_Face.vertices);
        mesh.SetIndices(m_Face.indices, MeshTopology.Triangles, 0, false);
        if (m_Face.normals.IsCreated)
        {
            mesh.SetNormals(m_Face.normals);
        }
        else
        {
            mesh.RecalculateNormals();
        }

        var (positionList, normalList, colorList) = DividePolygon(mesh);

        if (m_PositionMap == null || m_PositionMap.width * m_PositionMap.height < positionList.Count)
        {
            CreateVfxAttributeMaps(positionList.Count);
        }

        var bufSize = m_PositionMap.width * m_NormalMap.height;
        var posBuf = new Color[bufSize];
        var nrmBuf = new Color[bufSize];
        var colBuf = new Color[bufSize];
        for (var i = 0; i < positionList.Count; ++i)
        {
            var pos = positionList[i];
            var nrm = normalList[i];
            var col = colorList[i];
            posBuf[i] = new Color(pos.x, pos.y, pos.z, 1.0f);
            nrmBuf[i] = new Color(nrm.x, nrm.y, nrm.z, 0.0f);
            colBuf[i] = new Color(col.x, col.y, col.z, 0.0f); 
        }
        m_PositionMap.SetPixels(posBuf);
        m_PositionMap.Apply();
        m_NormalMap.SetPixels(nrmBuf);
        m_NormalMap.Apply();
        m_ColorMap.SetPixels(colBuf);
        m_ColorMap.Apply();
    }

    (List<Vector3>, List<Vector3>, List<Vector3>) DividePolygon(Mesh mesh)
    {
        var triangles = mesh.triangles;
        var vertices = mesh.vertices;
        var normals = mesh.normals;

        var posList = new List<Vector3>();
        var normalList = new List<Vector3>();
        var colorList = new List<Vector3>();

        for (var i = 0; i < triangles.Length; i += 3)
        {
            var idx0 = triangles[i];
            var idx1 = triangles[i + 1];
            var idx2 = triangles[i + 2];

            var pos0 = vertices[idx0];
            var pos1 = vertices[idx1];
            var pos2 = vertices[idx2];

            var nrm0 = normals[idx0];
            var nrm1 = normals[idx1];
            var nrm2 = normals[idx2];

            var col0 = SampleCameraFeedTexture(pos0);
            var col1 = SampleCameraFeedTexture(pos1);
            var col2 = SampleCameraFeedTexture(pos2);

            var area = Vector3.Cross(pos1 - pos0, pos2 - pos0).magnitude * 0.5f;
            var pointNum = Math.Min(Mathf.CeilToInt(area * m_PointPerArea), m_MaxPoints - posList.Count);

            for (var pIdx = 0; pIdx < pointNum; ++pIdx)
            {
                var weight0 = UnityEngine.Random.value;
                var weight1 = UnityEngine.Random.value;
                if (weight0 + weight1 > 1f)
                {
                    weight0 = 1f - weight0;
                    weight1 = 1f - weight1;
                }
                var weight2 = 1f - weight0 - weight1;

                var pos = pos0 * weight0 + pos1 * weight1 + pos2 * weight2;
                var nrm = nrm0 * weight0 + nrm1 * weight1 + nrm2 * weight2;
                var col = col0 * weight0 + col1 * weight1 + col2 * weight2;

                posList.Add(pos);
                normalList.Add(nrm);
                colorList.Add(col);
            }

            if (posList.Count > m_MaxPoints)
            {
                break;
            }
        }

        return (posList, normalList, colorList);
    }

    Vector3 SampleCameraFeedTexture(Vector3 facePosition)
    {
        if (m_CameraFeedTexture == null)
        {
            return Vector3.zero;
        }
        var viewPosition = m_Camera.WorldToViewportPoint(transform.TransformPoint(facePosition));
        if (Input.deviceOrientation == DeviceOrientation.Portrait)
        {
            viewPosition = new Vector3(1.0f - viewPosition.y, viewPosition.x, viewPosition.z);
        }
        else if (Input.deviceOrientation == DeviceOrientation.PortraitUpsideDown)
        {
            viewPosition = new Vector3(viewPosition.y, 1.0f - viewPosition.x, viewPosition.z);
        }
        Color color = m_CameraFeedTexture.GetPixelBilinear(viewPosition.x, viewPosition.y).linear;
        return new Vector3(color.r, color.g, color.b);
    }

    void OnCameraFrameReceived(ARCameraFrameEventArgs args)
    {
        XRCameraImage cameraImage;
        m_CameraManager.TryGetLatestImage(out cameraImage);

        if (m_CameraFeedTexture == null || m_CameraFeedTexture.width != cameraImage.width || m_CameraFeedTexture.height != cameraImage.height)
        {
            m_CameraFeedTexture = new Texture2D(cameraImage.width, cameraImage.height, TextureFormat.RGBA32, false);
        }

        CameraImageTransformation imageTransformation = Input.deviceOrientation == DeviceOrientation.LandscapeRight ? CameraImageTransformation.MirrorY : CameraImageTransformation.MirrorX;
        XRCameraImageConversionParams conversionParams = new XRCameraImageConversionParams(cameraImage, TextureFormat.RGBA32, imageTransformation);

        NativeArray<byte> rawTextureData = m_CameraFeedTexture.GetRawTextureData<byte>();

        try
        {
            unsafe
            {
                cameraImage.Convert(conversionParams, new IntPtr(rawTextureData.GetUnsafePtr()), rawTextureData.Length);
            }
        }
        finally
        {
            cameraImage.Dispose();
        }

        m_CameraFeedTexture.Apply();
    }
}
```

スクリプトについて解説します。

`Awake`メソッドでは必要な参照の取得を行っています。

`OnEnable`メソッドでは顔の周りにパーティクルを出すための`Visual Effect`コンポーネントを持つオブジェクトを作成をしています。この`Visual Effect`で使用するVFX Graphについては後ほど解説します。
また、`OnEnable`メソッドでは顔の情報を取得するためのイベントの購読を行っています。`ARFace`の`updated`イベントでは`OnUpdated`メソッドを実行して顔の頂点情報を更新し、`ARCameraManager`の`frameReceived`イベントでは`OnCameraFrameReceived`メソッドを実行して最新のカメラ画像を取得しています。`OnDisable`メソッドでは`OnEnable`メソッドの後処理をしています。

`OnUpdated`メソッド内の処理について解説します。
まず以下の箇所で顔のトラッキングができているかを確認しています。顔がトラッキングできているかどうかをVFX Graphに送り、VFX Graph内ではそれに応じてパーティクルを出すかを制御します。

```cs
var tracking = m_Face.trackingState == TrackingState.Tracking;
m_VfxInstance.SetBool("Tracking", tracking);
```

顔のトラッキングができており、かつ頂点が存在する場合にはTransformをVFX Graphに設定して、Attribute Mapの更新を行います。`ARFace`から取得できるのは`ARFace`コンポーネントを持つオブジェクトに対する頂点の位置や法線なので、VFX Graph側ではこのTransformを利用して位置や法線をワールド空間に変換しています。VFX GraphにTransfromを設定する方法は`VFX Property Binder`コンポーネントの実装を参考にしました。

```cs
if (tracking && m_Face.indices.IsCreated && m_Face.vertices.IsCreated)
{
    m_VfxInstance.SetVector3("Transform_position", transform.position);
    m_VfxInstance.SetVector3("Transform_angles", transform.eulerAngles);
    m_VfxInstance.SetVector3("Transform_scale", transform.lossyScale);
    UpdateVfxAttributeMaps();
}
```

`UpdateVfxAttributeMaps`メソッドでは顔の頂点位置・法線の取得やAttribute Mapの更新を行っています。また、必要であれば`CreateVfxAttributeMaps`メソッドを呼び、Attribute Mapの作成を行います。スクリプトで動的にAttribute Mapを生成してVFX Graphに情報を渡す方法については[以前に記事](https://qiita.com/aa_debdeb/items/cd46d571dcb20f75817b)を書いたので、ここではFace Tracking固有のことのみ解説します。
まず、以下の箇所で`ARFace`から顔の頂点位置と法線を取得しています。`ARFace`に法線の情報が存在していない場合は、`RecalculateNormals`メソッドで法線の計算をしています。ちなみに私の環境では`m_Face.normals.IsCreated`は常に`false`でした。頂点情報をメッシュに入れているのは`RecalculateNormals`を使いたいためだけなので、もし`m_Face.normals.IsCreated`が常に`true`になることが保証されていたり法線が不要であればわざわざメッシュを作成する必要はないです。

```cs
var mesh = new Mesh();
mesh.SetVertices(m_Face.vertices);
mesh.SetIndices(m_Face.indices, MeshTopology.Triangles, 0, false);
if (m_Face.normals.IsCreated)
{
    mesh.SetNormals(m_Face.normals);
}
else
{
    mesh.RecalculateNormals();
}
```

取得した顔の頂点だけではパーティクルの生成点としては少なく、また鼻や口の周りなどに頂点が偏っているので`DividePolygon`メソッドでポリゴンの面積に応じて点の数を増やします。ちなみに私の環境では`m_Face.vertices`で取得できる頂点数は1220個でした。

```cs
var (positionList, normalList, colorList) = DividePolygon(mesh);
```

`DividePolygon`メソッドの実装に関しては以下の記事を参考にさせて頂いたので、そちらを参照してください。

[VFX Graph の可能性を探る！PointCache動的生成、VAT拡張｜fuqunaga｜note](https://note.com/fuqunaga/n/n8095638b6b14)

頂点の色は`ARFace`から取得できないので、`DividePolygon`メソッド内で`SampleCameraFeedTexture`メソッドを呼んで顔の位置をもとにカメラ画像から取得しています。

このカメラ画像は`OnCameraFrameReceived`メソッド内で取得しています。このメソッド内の処理は以下の記事を参考させて頂きました。

[AR FoundationでPeople Occlusionを実装する \- じゅころぐAR](https://www.jyuko49.com/entry/2019/10/15/094408)

最後に`m_VfxPrefab`に設定するVFX Graphについて解説します。
まず`Spawn`コンテキストの`Constant Spawn Rate`ブロックではFace Trackingができていない場合、パーティクルを出さないようにしています。
`Initialize Particle`コンテキストでは位置を`Position Map`プロパティから、速度を`Normal Map`プロパティから、色を`Color Map`プロパティからサンプリングしています。ここでの位置と法線は顔空間におけるものなので、`Transform`プロパティを用いてワールド空間のものに変換し直しています。
このパーティクルを`Output Particle Cube`コンテキストでキューブとして表示しています。
<img width="1215" alt="スクリーンショット 2020-05-07 22.44.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/ac1c991b-d53b-e596-266e-9f1dda9e3095.png">

---

UnityでAR FoundationのFace TrackingとVFX Graphを組み合わせて顔からパーティクルを出す方法について解説しました。

今回は一度生成したパーティクルを顔の動きとは無関係にしたかったので、`Visual Effect`コンポーネントを`ARFace`コンポーネントとは別オブジェクトにしました。もし生成したパーティクルを顔の動きと連動させたい場合は`Visual Effect`コンポーネントを`ARFace`コンポーネントと同じオブジェクトに持たせてください。

一つだけ上手くいかなかったことがあり、パーティクルの色をカメラ画像のものと全く同じに合わせることができませんでした。おそらく色空間の問題な気がしていますが、もし分かる人がいれば教えていただけるとありがたいです。

---

追記(2020/05/08)

色がおかしくなる問題を解決できました。原因は色空間ではなく、VFX Graphの`Output Particle Cube`コンテキストで`Main Texture`に何も設定していないことでした。ここに白いテクスチャを設定したところ、カメラ画像と同じ色で表示されるようになりました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">色がおかしくなるの直せた <a href="https://t.co/bOHcuWqLAs">pic.twitter.com/bOHcuWqLAs</a></p>&mdash; aadebdeb / Atsushi Asakura (@aa_debdeb) <a href="https://twitter.com/aa_debdeb/status/1258690882993152001?ref_src=twsrc%5Etfw">May 8, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>