はじめに
ふと表題にもあるFace Tracking with ARKitとVFX Graphを組み合わせて何かエモいことができないかと思い、以下の様なデモを作ってみました。 STGのラスボスに使えそう感。
シンプルな演出ではありますが、上記の実装でやっている事を簡単に纏めるとARKitから取得したFaceGeometry
をVFX Graph側で読み込んで頂点位置にパーティクルを発生させている感じです。
演出と言うよりは内部実装メインのお話にはなりますが、今回はこちらを実装した際の技術解説について簡単に纏めてみようと思います。
サンプルプロジェクトについて
サンプルプロジェクトはGitHubにて公開済みです。
-
Unity version
- 2019.1.0f2
-
Packages
- LWRP 5.7.2
- ※VFX GraphでLWRPを使用する手順については以下を参照。
- Visual Effect Graph preview-5.13.0
- LWRP 5.7.2
※注意点 : VFX Graph自体はまだPreviewなので将来的に変更が入る可能性があります。ご了承ください。
シーンの方はAssets/FaceTracking-VFX/Scenes/FaceTracking-VFX.unity
を御覧ください。
今回はこちらを主軸に解説していきます。
ARKitについて
今回Unity上でARKitを使うにあたっては、「Unity-ARKit-Plugin」と言う公式で用意されているパッケージを利用することにしました。
※一応最新版辺りであれば、PackageManagerからARKit XR Pluginと言うパッケージを取得して利用する事が出来るみたいですが...こちらについては情報を調べきれていないので今回は敢えて未使用。
リモート機能について
後は前提としてFace Tracking with ARKit
を使用しているので、動かすにはiPhoneXに相応するスペックを有するiOS端末が必要となります。
その上で端末のカメラやセンサーなどを使うので動作確認するなら必然的に実機上で動かす必要が出てきますが、値を調整する度にいちいちビルドしてトライアンドエラーを行うのは非常にイテレーションが悪くなります。
その為に今回は「Unity-ARKit-Plugin」に含まれるリモート機能を用いた上で値の調整を行いました。
覚えておくと便利な機能ではあるので簡単に触れておきます。
実行手順
実行手順及び詳細についてはこちらの公式ブログにほぼ載ってます。
一応手順を記載。
※ちなみにリモート機能自体は以下の1~2の手順で作成するリモート用アプリのビルド/インストールまで対応出来れば、後はiOS端末をWindowsマシンに繋げる形でリモート実行することも可能。
-
- EditorのBuildSettingsを開いて以下の設定を行う。
- PlatformをiOSに設定。
- 既に登録されているであろう
FaceTracking-VFX.unity
をビルドに含めないようにチェックを外した上で、新規にAssets/UnityARKitPlugin/ARKitRemote/UnityARKitRemote.unity
をビルド対象に含める。 -
Development Build
にチェックを入れる。
-
- ビルドして実機に入れる。
- ※アプリのIDが競合しないようにビルドする際には予めBundleIdに
.remote
と言う文字列を含めるなりして既存の物とは別物として管理しておくと良いかもしれない。
-
-
FaceTracking-VFX.unity
を開く。
-
-
- 上記のビルドを入れたiOS端末とPCをLightningケーブル等で接続してリモートアプリを実行。
-
- Editor上でConsole Windowの検索ボックスの左にある項目を選択 → 接続先を4の手順で繋げたiOS端末に変更。
-
- Editor上で
FaceTracking-VFX.unity
を実行。
- Editor上で
-
- 画面中に「Start ARKit Face Tracking Session」と記載されたボタンが表示されているはずなので押下。成功するとリモート接続される。
ちなみにリモート接続時はプラグイン側によるカメラ向き調整の影響か、 縦持ちだとカメラがZ軸に90℃回転している様に見受けられました。 1
これが影響してくる点で言えば、例えば以下の様にVFX Graphでパーティクルに重力をかけて下に落下していくような処理を入れたとすると、リモート時にはパーティクルが横に動いてしまうと言ったことが発生します。
今回は大雑把な動きを見られればよかったので特に対応はしておりませんが、必要であればリモート時には何かしらの補正を入れてみても良いかもしれません。
実装について
実装について解説していきます。
先に要点だけ簡潔に纏めておくと全体の流れとしては以下の手順となります。
-
Unity-ARKit-Plugin
からFaceGeometry
を取得。(主に使うのは頂点の位置) - ↑で得た
FaceGeometry
の頂点情報をRenderTextureに書き込み。 - VFX Graph側ではTextureに書き込まれた
FaceGeometry
の情報をSet From Position Map
と言うBlockを経由して取得。得た座標情報を元にパーティクルを表示。
後は前提として上記「2」の手順ではComputeShaderを利用しているので、こちらの知識が多少必要となるかもしれません。。2
こちらの記事中では詳細な解説については割愛しますが、以下に参考リンクの幾つかを載せておきます。
後は少し古い記事となりますが、ComputeShaderの基礎知識については以下の記事等も分かりやすいかと思われます。
- [Unity] UnityでComputeShaderを使う解説をしているページを訳してみた その1
- [Unity] UnityでComputeShaderを使う解説をしているページを訳してみた その2
- [Unity] UnityでComputeShaderを使う解説をしているページを訳してみた その3
FaceGeometryの取得
FaceGeometry
の取得についてはUnity-ARKit-Plugin
の中に「顔の頂点情報などを取得して動的にMeshを構築して表示する」サンプルがあったので、今回はこちらをベースにして実装の方を進めました。
※サンプルのシーンはAssets/UnityARKitPlugin/Examples/FaceTracking/FaceMeshScene.unity
が該当。
実行すると以下のようにシンプルな顔のメッシュが表示されるかと思われます。(シーン自体はリモート実行可能)
顔のMeshを構築する処理についてはシーン中にあるARFaceMeshManager
と言うGameObjectが担っており、以下のように同じGameObjectに所属しているMeshRendererに対しプラグイン側から動的に頂点情報を流し込んで構築しているように見受けられました。
処理の方もそんなに複雑ではなく、プラグインが持つARFaceAnchor***Event
と言うイベントに更新したいメソッドを登録し、後はイベント経由で流れてくるARFaceAnchor
の値を元にMeshを構築しているみたいです。
以下にコードの一部を載せておきます。
// Use this for initialization
void Start()
{
m_session = UnityARSessionNativeInterface.GetARSessionNativeInterface();
Application.targetFrameRate = 60;
ARKitFaceTrackingConfiguration config = new ARKitFaceTrackingConfiguration();
config.alignment = UnityARAlignment.UnityARAlignmentGravity;
config.enableLightEstimation = true;
if (config.IsSupported && meshFilter != null)
{
m_session.RunWithConfig(config);
UnityARSessionNativeInterface.ARFaceAnchorAddedEvent += FaceAdded;
UnityARSessionNativeInterface.ARFaceAnchorUpdatedEvent += FaceUpdated;
UnityARSessionNativeInterface.ARFaceAnchorRemovedEvent += FaceRemoved;
}
}
void FaceAdded(ARFaceAnchor anchorData)
{
gameObject.transform.localPosition = UnityARMatrixOps.GetPosition(anchorData.transform);
gameObject.transform.localRotation = UnityARMatrixOps.GetRotation(anchorData.transform);
faceMesh = new Mesh();
faceMesh.vertices = anchorData.faceGeometry.vertices;
faceMesh.uv = anchorData.faceGeometry.textureCoordinates;
faceMesh.triangles = anchorData.faceGeometry.triangleIndices;
// Assign the mesh object and update it.
faceMesh.RecalculateBounds();
faceMesh.RecalculateNormals();
meshFilter.mesh = faceMesh;
}
void FaceUpdated(ARFaceAnchor anchorData)
{
if (faceMesh != null)
{
gameObject.transform.localPosition = UnityARMatrixOps.GetPosition(anchorData.transform);
gameObject.transform.localRotation = UnityARMatrixOps.GetRotation(anchorData.transform);
faceMesh.vertices = anchorData.faceGeometry.vertices;
faceMesh.uv = anchorData.faceGeometry.textureCoordinates;
faceMesh.triangles = anchorData.faceGeometry.triangleIndices;
faceMesh.RecalculateBounds();
faceMesh.RecalculateNormals();
}
}
void FaceRemoved(ARFaceAnchor anchorData)
{
meshFilter.mesh = null;
faceMesh = null;
}
纏めると、今回のアプローチとしては上記のイベント経由でARFaceAnchor
を取得し、そこから得られた情報を元にして後述のComputeShader経由でTextureに頂点情報を書き込むと言った流れとなります。
Mesh情報のTextureへの書き込み
次に表題のTextureへの書き込みについて解説していきます。
VFX GraphでMeshの情報を参照するには
そもそもとして「何故Meshの情報をTextureに書き込まなくてはならないのか?」について簡単に触れておくと、VFX Graph自体はMeshの形状に沿ったパーティクルを出すと言った事は出来るものの、データとしてMeshを直接参照することは出来ないので、一度ポイントキャッシュという形でデータを出力して読み込ませる必要があります。
その為に「静的なMesh」に対してならVFX Graph側の機能として予め「Meshからポイントキャッシュ形式にデータを出力するツール3」が付属されているので、こちらを用いることで事前に変換して読み込ませる事が可能となりますが、SkinnedMeshRendererと言った動的に変形するMeshについては対応しておらず、仮に使うとした場合には何かしらの工夫が必要となるように思われました。
ポイントキャッシュについて
ポイントキャッシュがVFX Graph側でどう読み込まれているのかについても解説しておきます。
生成されたポイントキャッシュはVFX Graph上でPoint Cache
と言うノードを経由して読み込むことができ、Attribute Mapと言う名のTexture形式として出力することが可能です。(※正確に言うとTextureにポイントキャッシュの情報を焼き込んでいるとのこと。)
その上で出力されたAttribute MapはSet Position from Map
と言ったノードを介して読み込むことでパーティクルに適用する事が可能となります。
ここらのお話については以前開催された「Unity道場」と言うイベントにて「Visual Effect GraphとRealSenseを組み合わせると 簡単に派手なことができてすごく面白いという話」と言う講演があり、こちらの方で具体的に解説されております。
その時の講演資料の模式図の一部を引用すると以下のような流れとなります。
ポイントキャッシュ自体は上記の様に読み込まれる際の形式としてはTextureとなっているので、今回の実装のアプローチとしては「Meshの情報を動的にTexture(Attribute Map)に書き込むことでVFX Graph側で参照する事が出来ないか?」を検証してみた物となります。
Textureへの書き込みについて
当初は上述の「Meshからポイントキャッシュ形式にデータを出力するツール」を参考に処理を書いていこうかと思いましたが、調べてみると既にkeijiroさんがSkinnedMeshRendererの情報を動的にTextureに書き込んでVFX Graph側で参照する「Smrvfx」と言うリポジトリを公開されていたので、こちらの方を参考にさせて頂きました。
内容としては、まさに今回やろうとしていることそのものです。
Smrvfxの内容を簡単に纏めると、「SkinnedMeshRendererからBakeMeshと言うメソッドを呼び出してMeshを生成」 → 「頂点/法線/モーションベクターと言った情報をComputeShaderでTextureに書き込み」 → 「VFX Graphで参照」と言った流れとなっているように見受けられました。
こちらもやろうとしている事はほぼ同様のものであり、纏めると以下の手順となります。
-
Unity-ARKit-Plugin
からFaceGeometry
の情報を取得 - 取得した
FaceGeometry
の頂点情報をComputeShaderでTextureに書き込み - 書き込んだTextureをVFX Graphで参照。
順に詳細について解説していきます。
1. FaceGeometryの取得
前述のUnityARFaceMeshManager
を参考に以下のスクリプトを実装しました。
今回はこちら経由で頂点情報を流していってます。
処理自体は参考にしたUnityARFaceMeshManager
とほぼ変わらないものであり、違いを上げるとしたらMeshを生成せずに直接頂点などの情報をComputeShaderに流している点などが上げられます。
その他の補足についてはコード中にコメントを幾つか残してあるので、詳細についてはそちらを御覧ください。
sealed class ARFaceMeshBaker : MonoBehaviour
{
[SerializeField] RenderTexture _positionMap = default;
[SerializeField] ComputeShader _vertexBaker = default;
UnityARSessionNativeInterface _session;
ComputeBuffer _positionBuffer;
RenderTexture _tmpPositionMap;
int _vertexCountID, _transformID, _positionBufferID, _positionMapID;
void Start()
{
// ComputeShader側に渡す各種パラメータ
_vertexCountID = Shader.PropertyToID("VertexCount");
_transformID = Shader.PropertyToID("Transform");
_positionBufferID = Shader.PropertyToID("PositionBuffer");
_positionMapID = Shader.PropertyToID("PositionMap");
// 以下はARKit周りの初期化
_session = UnityARSessionNativeInterface.GetARSessionNativeInterface();
Application.targetFrameRate = 60;
var config = new ARKitFaceTrackingConfiguration();
config.alignment = UnityARAlignment.UnityARAlignmentGravity;
config.enableLightEstimation = true;
if (config.IsSupported)
{
_session.RunWithConfig(config);
UnityARSessionNativeInterface.ARFaceAnchorAddedEvent += FaceAdded;
UnityARSessionNativeInterface.ARFaceAnchorUpdatedEvent += FaceUpdated;
UnityARSessionNativeInterface.ARFaceAnchorRemovedEvent += FaceRemoved;
}
}
// ComputeShaderに渡すバッファの生成
void FaceAdded(ARFaceAnchor anchorData)
{
var vertexCount = anchorData.faceGeometry.vertices.Length;
_positionBuffer = new ComputeBuffer(vertexCount * 3, sizeof(float));
_tmpPositionMap = _positionMap.Clone();
}
// 取得した頂点情報の書き込み
void FaceUpdated(ARFaceAnchor anchorData)
{
if (_positionBuffer == null) return;
var mapWidth = _positionMap.width;
var mapHeight = _positionMap.height;
var vCount = anchorData.faceGeometry.vertices.Length;
// FaceGeometryから頂点とtransformの取得を行う
_positionBuffer.SetData(anchorData.faceGeometry.vertices);
gameObject.transform.localPosition = UnityARMatrixOps.GetPosition(anchorData.transform);
gameObject.transform.localRotation = UnityARMatrixOps.GetRotation(anchorData.transform);
// バッファに確保した頂点とtransformをComputeShaderにセットして実行。
// ※transformはComputeSahder内で頂点に対するワールド変換を行う際に使用する。
_vertexBaker.SetInt(_vertexCountID, vCount);
_vertexBaker.SetMatrix(_transformID, gameObject.transform.localToWorldMatrix);
_vertexBaker.SetBuffer(0, _positionBufferID, _positionBuffer);
_vertexBaker.SetTexture(0, _positionMapID, _tmpPositionMap);
// ComputeShaderの実行
//
// Inspectorから指定される_positionMapのwidthとheightは「512 x 512」となるので、
// ComputeShaderとしては「group(64, 64, 1)」「numthreads(8, 8, 1)」
// → 「(64 * 8, 64 * 8, 1) → 512 x 512」のバッファに対する書き込みを行う事となる。
_vertexBaker.Dispatch(0, mapWidth / 8, mapHeight / 8, 1);
Graphics.CopyTexture(_tmpPositionMap, _positionMap);
}
void FaceRemoved(ARFaceAnchor anchorData)
{
_positionBuffer.TryDispose();
_tmpPositionMap.TryDestroy();
}
}
ちなみに_positionMap
には以下のRenderTextureを設定してます。
2. ComputeShaderでの書き込み
書き込んでいる処理はCSMain
です。
単純にC#側から渡されたTextureに座標情報を書き込んでいるものとなります。4
※補足: group/threadsの指定数について
numthreadsでは(8, 8, 1)を指定しており、これは言い換えると1グループ辺り「8 x 8 x 1 = 64 (pixel)」分処理される事を指します。
その上でDispatch時のグループの指定としては「64 x 64 x 1 = 4096」を指定しており、結果として「64 (1グループ辺りで処理されるpixel数) x 4096 (処理する全グループ数) = 262144 (処理される全ピクセル数)」が処理される事となります。
この結果は言い換えると「512 x 512サイズのテクスチャの総ピクセル数(262144)」分がComputeShaderで処理される事を指します。
// refered to:
// https://github.com/keijiro/Smrvfx
// Smrvfx/Assets/Smrvfx/SkinnedMeshBaker.compute
#pragma kernel CSMain
uint VertexCount;
float4x4 Transform;
StructuredBuffer<float> PositionBuffer;
RWTexture2D<float4> PositionMap;
// Hash function from H. Schechter & R. Bridson, goo.gl/RXiKaH
uint Hash(uint s)
{
s ^= 2747636419u;
s *= 2654435769u;
s ^= s >> 16;
s *= 2654435769u;
s ^= s >> 16;
s *= 2654435769u;
return s;
}
// ※Dispatch(0, 64, 64, 1) で実行される想定。
[numthreads(8,8,1)]
void CSMain (uint2 id : SV_DispatchThreadID)
{
uint i = Hash(id.x + id.y * 65536) % VertexCount;
float3 position = float3(
PositionBuffer[i * 3],
PositionBuffer[i * 3 + 1],
PositionBuffer[i * 3 + 2]);
position = mul(Transform, float4(position, 1)).xyz;
PositionMap[id] = float4(position, 1);
}
3. 書き込んだTextureをVFX Graphで参照。
後はVFX GraphのInitializeコンテキストにSet Position from Map
と言うBlockを付け加えて、Attribute Map
の所に書き込まれるRenderTextureを指定することで参照することが可能です。
小さいかもしれませんが...冒頭に乗せているSTGのラスボスとして出てきそうな演出のグラフの一部を載せると以下のようになります。
Set Position from Map
で指定された位置に対しCubeをランダムなサイズで生成 → Updateで重力を掛けて落下させてます。
他にもSet Color over life
で寿命に応じて色を変えつつSet Angle
で回転をかけたりもしてます。
グラフ全体についてはプロジェクト内の「CubeFace」を御覧下さい。
参考/関連サイト
-
Visual Effect GraphとRealSenseを組み合わせると 簡単に派手なことができてすごく面白いという話
- 今回の検証を行うにあたってインスピレーションを受けた講演。
ARKit関連
- Unity-ARKit-Plugin
-
ARKit XR Plugin
- ※PackageManagerから取得可能なもの。今回は未使用。
- Creating Face-Based AR Experiences
VFX Graph関連
GitHub
-
keijiro/Smrvfx
- Meshを動的にComputeShaderでTextureに書き込んでVFX Graphに渡すところを参考。
- Unity-Technologies/VisualEffectGraph-Samples
公式ブログ/動画
- Visual Effect Graph を使った爆発エフェクトの作成
- Visual Effect Graph Samples
-
FIRE AND SMOKE with Unity VFX Graph!
- ※ 煙炎を実装するデモ動画。分かりやすい。
-
※シーン中に
ARCameraTracker.cs
と言う「端末上のカメラの動きをUnity上のCamera.transformに反映させるスクリプト」が存在しますが、この中にDebug.Logを仕掛けて値を見た感じだとプラグインから渡ってくる値レベルで既に回転しているように見受けられた。 ↩ -
詳細な挙動を把握せずに使うだけであれば不要かもしれませんが。。 ↩
-
※ツール自体はメニューバーの「Window -> Visual Effect -> Utilities -> Point Cache Bake Tool」から起動可能。 ↩
-
書き込む際にHash関数経由でPositionBufferのindexを求めているが、恐らくはindexの衝突を避けるために設定説。(例えばSV_DispatchThreadIDに(1, 0)と(0, 1)が渡ってきたとしたら、単純に加算するだけだと同じindexに書き込むことになるので) ↩