hz-unity-face-tracking
Drive ARKit-blendshape-rigged head/face models in Unity with the wearer's facial expressions on Meta VR via Meta Movement SDK (face tracking + A2E). Use when a user has an FBX with the 52 ARKit blendshapes (any prefix, _L/_R suffixes) and wants it to animate from face tracking on Quest Pro / Quest 3 / Quest 3S.
How do I install this agent skill?
npx skills add https://github.com/meta-quest/agentic-tools --skill hz-unity-face-trackingIs this agent skill safe to install?
- Gen Agent Trust Hubpass
This skill provides legitimate Unity scripts and detailed instructions for implementing face and eye tracking on Meta Quest headsets using the Meta Movement SDK. No security risks were identified.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Unity Face Tracking for ARKit-Rigged Models (Meta Movement SDK)
End-to-end recipe to make a head/face model rigged with the standard 52 ARKit blendshapes animate from the wearer's face on Meta VR. Uses the public OVRCustomFace extension hook — no OVR_INTERNAL_CODE, ships to 3P.
The model's blendshape names must follow the ARKit naming convention (camelCase, _L/_R suffixes, e.g. eyeBlink_L, jawOpen, mouthSmile_R). An optional prefix like blendShape2.eyeBlink_L is automatically stripped.
When to use
- User has an FBX/GLB/mesh with ARKit-named blendshapes and wants it driven by Meta VR face tracking.
- User asks: "animate this head with my face", "drive these blendshapes from face tracking", "use Movement SDK A2E with my model", "wire ARKit shapes to Meta VR".
- Target device: Quest Pro, Quest 3, Quest 3S (Quest 2 is no-op — no face cameras).
Prerequisites checklist
- Quest face tracking-capable headset (Pro / 3 / 3S).
- Packages in
Packages/manifest.json:com.meta.xr.sdk.core(Meta XR Core — providesOVRFaceExpressions,OVRCustomFace)com.meta.xr.sdk.movement(Meta Movement SDK — A2E + retargeting helpers)
Assets/Oculus/OculusProjectConfig.asset(verify via Project Settings → Meta XR):faceTrackingSupport: 1(Supported) or2(Required)eyeTrackingSupport: 1if the rig has gaze
- Android manifest (
Assets/Plugins/Android/AndroidManifest.xml) permissions:<uses-feature android:name="oculus.software.face_tracking" android:required="false" /><uses-permission android:name="com.oculus.permission.FACE_TRACKING" /><uses-permission android:name="android.permission.RECORD_AUDIO" />(required for A2E)- For eye gaze:
oculus.software.eye_tracking+com.oculus.permission.EYE_TRACKING
- OVRCameraRig in the scene with
OVRManager.FaceTrackingDataSourcesincludingAudio(A2E) — if you skip Audio, mouth motion is visual-only.
After any change to OculusProjectConfig, regenerate the manifest (see Regenerating the manifest below).
Approach (high level)
- Drop the
ARKitOVRCustomFacescript (inreferences/ARKitOVRCustomFace.cs) into the project. - Add an
OVRFaceExpressionscomponent to the OVRCameraRig (or anywhere in the scene). - On the GameObject that has the model's
SkinnedMeshRenderer, addARKitOVRCustomFace. Adding the component triggersReset(), which auto-populates the blendshape→FaceExpression mapping by scanning the mesh's blendshape names. - Wire the component's
FaceExpressionsfield to theOVRFaceExpressionsinstance. - For eye gaze: drop
ARKitEyeGazeBlendshapeDriverin (see Eye tracking below). - Press Play with Meta XR Link, or build APK and side-load. On first launch, accept the Face Tracking / Eye Tracking / Microphone permission prompts.
That's the whole flow. Details below.
Eye tracking (DO THIS — face expressions alone don't work)
Don't rely on OVRFaceExpressions.EyesLook* for eye gaze. Those fields are derived from face-camera visuals, not the dedicated eye tracker. On Quest Pro they're often zero or noisy even when face tracking is otherwise working. The right API is OVREyeGaze, which taps the eye tracker directly.
For ARKit rigs with eyeLook* blendshapes (no eye bones), this skill ships ARKitEyeGazeBlendshapeDriver:
- Reads gaze rotation from two
OVREyeGazecomponents (one per eye,TrackingMode = HeadSpace). - Computes rotation relative to a head reference (e.g.
CenterEyeAnchor). - Decomposes pitch →
eyeLookUp/Down_{L,R}, yaw →eyeLookIn/Out_{L,R}(with the ARKit "_In = toward nose" convention). - Writes weights in
LateUpdate, so it overrides whateverARKitOVRCustomFacewrote inUpdate.
For rigs with eye bones (no eyeLook* blendshapes), skip the blendshape decomposition and just parent OVREyeGaze to each eye bone with ApplyRotation = true — the component will rotate the bone directly.
Eye gaze setup (blendshape rigs)
- Create two empty GameObjects under
CenterEyeAnchor(or your head transform):LeftEyeGaze,RightEyeGaze. - Add
OVREyeGazeto each. SetEye = Left/Right,TrackingMode = HeadSpace,ApplyPosition = false,ApplyRotation = true,ConfidenceThreshold = 0.5. - Add
ARKitEyeGazeBlendshapeDriverto the head's SkinnedMeshRenderer GameObject (alongsideARKitOVRCustomFace). WireleftEye,rightEye, andreferenceFrame(=CenterEyeAnchor). - Tweak
maxAngleDeg(default 30°) andsmoothing(default 0.4) to taste.
Step-by-step
1. Install the script
Copy references/ARKitOVRCustomFace.cs into Assets/Scripts/ARKitOVRCustomFace.cs. It defines the public, 3P-shippable ARKit ↔ OVR FaceExpression table and a MapBlendshapes() method that scans SkinnedMeshRenderer.sharedMesh.GetBlendShapeName(i), strips any prefix before the last ., lowercases, and matches against the table. Unmatched mesh blendshapes are set to OVRFaceExpressions.FaceExpression.Max (sentinel — skipped at runtime).
2. Enable Movement SDK + permissions
Project settings:
Project Settings → Meta XR → Face Tracking Support = Supported
Project Settings → Meta XR → Eye Tracking Support = Supported (if needed)
Then regenerate the manifest.
Regenerating the manifest
Against a live Editor, call the SDK's generator through run_script — the type resolves directly, so
no reflection is needed:
// AgentScripts/UpdateManifest.cs
public static class UpdateManifest
{
public static string Run()
{
OVRManifestPreprocessor.GenerateOrUpdateAndroidManifest(true); // silentMode: true
return "ok";
}
}
unity command run_script --file AgentScripts/UpdateManifest.cs --entry UpdateManifest.Run --format json
silentMode: true is required — without it the generator opens a modal dialog that blocks the
request. Then confirm the face-tracking entries from the checklist above actually landed in
Assets/Plugins/Android/AndroidManifest.xml. See hz-unity-meta-core-sdk for the full manifest
workflow and the rule against hand-editing managed entries.
Alternatives: the Editor menu Meta → Tools → Update AndroidManifest.xml, or the
meta_update_android_manifest tool if you are on a Unity MCP server with the Meta Unity extension.
3. Scene setup
Scene Hierarchy
├── OVRCameraRig (OVRQuickActionsAPI.AddOVRInteractionRig(), or the
│ │ OVRCameraRig prefab; meta_add_camerarig under MCP)
│ └── (add) OVRFaceExpressions component
└── YourHeadModel
└── ...SkinnedMeshRenderer GO...
├── SkinnedMeshRenderer (existing)
└── (add) ARKitOVRCustomFace component
└── FaceExpressions = the OVRFaceExpressions ref
└── retargetingType = Custom (set automatically by base when overriding)
└── Mappings[] = auto-filled on Reset()
└── BlendShapeStrengthMultiplier = 100 (default; OVR weights are 0–1, mesh wants 0–100)
If the head has multiple SkinnedMeshRenderers (e.g. separate teeth/tongue meshes), add ARKitOVRCustomFace to each one.
4. OVRManager — enable Audio as a data source (A2E)
On OVRCameraRig's OVRManager component:
- Face Tracking Data Sources → check Visual AND Audio (Audio = A2E; produces mouth shapes from microphone when the visual face cameras can't see something — talking, occlusion, etc.).
5. Trigger the mapping if the component already existed
MapBlendshapes() runs automatically when the component is added (Reset()) and from OnValidate() when Mappings is empty. If you need to remap manually (e.g. after re-importing the FBX), use the component's inspector context menu → Map Blendshapes, or from a script:
go.GetComponent<ARKitOVRCustomFace>().MapBlendshapes();
Against a live Editor via run_script, the project script's type is directly referenceable (it lives
in Assets/), so just call it:
// AgentScripts/Remap.cs
using UnityEngine;
public static class Remap
{
public static string Run(string modelName)
{
var smr = GameObject.Find(modelName).GetComponentInChildren<SkinnedMeshRenderer>();
smr.gameObject.GetComponent<ARKitOVRCustomFace>().MapBlendshapes();
return $"Remapped {smr.sharedMesh.blendShapeCount} blendshapes on {smr.name}";
}
}
unity command run_script --file AgentScripts/Remap.cs --entry Remap.Run --args '["YourHeadModel"]' --format json
unity command save_scene --format json
Under a Unity MCP server the OVR/MSDK types are not visible to the dynamic assembly, so the
reflection-free workaround there is SendMessage:
GameObject.Find("YourHeadModel")
.GetComponentInChildren<SkinnedMeshRenderer>().gameObject
.SendMessage("MapBlendshapes", SendMessageOptions.RequireReceiver);
6. Verify
-
In Editor, with Meta XR Link / Quest Link, enter Play mode and make faces. The model should mirror them. Driving that loop from the CLI:
unity command clear_console --format json unity command editor_play --format json # blocks until play mode is live # --- the wearer now makes faces; ask the user to confirm before continuing --- unity command get_console_logs --severity error --format json unity command editor_stop --format jsonDo not chain these four back to back.
editor_stopimmediately aftereditor_playleaves no time for anyone to make a face, so the check proves nothing. Enter Play mode, tell the user to make faces and report what they see, and only then read the log and stop. -
On device, build APK, side-load (
metavr app install <apk>), grant Face Tracking + Microphone permissions on first launch, and streammetavr adb logcat --follow --tag Unitywhile making faces to catch mapping errors. -
Check the mapping at edit time: inspect the
ARKitOVRCustomFacecomponent.Mappings.Lengthshould equalSkinnedMeshRenderer.sharedMesh.blendShapeCount. The Console log fromMapBlendshapes()reportsmapped X/N blendshapes— X should be 50 (or 52 if the FBX has all of them). Read the serialized array length without opening the Inspector —--fieldtakes a SerializedProperty path, so it wants the[SerializeField]backing field_mappingsonOVRCustomFace, not the publicMappingsproperty, which is not serialized:unity command get_serialized_fields --target <head GO> --component ARKitOVRCustomFace --field _mappings --format json
Troubleshooting
| Symptom | Cause / fix |
|---|---|
mapped 0/N in Console | Mesh blendshape names don't follow ARKit convention. Verify with mesh.GetBlendShapeName(i). Names must match e.g. eyeBlink_L, jawOpen — case-insensitive, prefix before last . is stripped. |
| Face is frozen | OVRFaceExpressions not assigned, or scene has no OVRCameraRig/OVRManager with face tracking enabled. Check OVRFaceExpressions.FaceTrackingEnabled and ValidExpressions at runtime. |
| Mouth doesn't move when speaking | A2E disabled. Enable Audio under OVRManager → Face Tracking Data Sources, and ensure RECORD_AUDIO permission is granted on device. |
| Eyes don't blink | Mesh's eyelid shapes aren't named eyeBlink_L/_R. Either rename, or add a custom row to ARKitTable. |
| Eyes don't move (look around) | You're relying on OVRFaceExpressions.EyesLook* instead of OVREyeGaze — switch to ARKitEyeGazeBlendshapeDriver. |
| Eye gaze in wrong direction | Note the deliberate ARKit ↔ OVR swap: ARKit's eyeLookIn_L (eye looking nose-ward, i.e. right) maps to OVR EyesLookRightL. The table already does this — don't "fix" it. |
| Weights look half-strength or clamped | BlendShapeStrengthMultiplier defaults to 100 because Unity blendshapes are 0–100 while OVR is 0–1. Don't lower this unless intentional. |
cannot change access modifiers when overriding compile error | Base method is protected internal in another assembly. The override must use protected (not protected internal) — already correct in the supplied script. |
_mappings out of sync with shared mesh assertion at Start | Mesh changed since mapping was generated. Re-run MapBlendshapes() via the component context menu. |
ARKit ↔ OVR FaceExpression mapping (reference)
The 52 ARKit shapes don't 1:1 a FACS-based OVR enum. Notable choices baked into ARKitTable:
- Centrally-named ARKit shapes that have L+R OVR pairs (
browInnerUp,cheekPuff,mouthFunnel,mouthPucker,mouthRollLower,mouthRollUpper) pick only the L-side OVR expression. If your model has obviously asymmetric mouth/brow when the user uses these expressions and you want true symmetric drive, subclass and additively sum L+R in a customMapBlendshapes(useSlothARKitFaceDriver-style per-blendshape sum). For most heads the L-only choice is fine because the face is roughly symmetric and the asymmetry is below visual threshold. eyeLookIn/Outare deliberately swapped per side relative to OVR's left/right semantics (see Troubleshooting).mouthClose→LipsToward(closest FACS analogue).tongueOut→TongueOut(requires HorizonOS ≥ 65 + tongue tracking; otherwise stays at 0).
Files in this skill
SKILL.md— this filereferences/ARKitOVRCustomFace.cs— drop-inOVRCustomFacesubclassreferences/ARKitEyeGazeBlendshapeDriver.cs— OVREyeGaze →eyeLook*blendshape driver (LateUpdate)
Why this approach (vs. alternatives)
- Custom
MonoBehaviourdriver that readsOVRFaceExpressions[expr]and writes blendshape weights directly: works, but doesn't integrate with MSDK's correctives, eye constraints, or future retargeting upgrades. Use only if you can't extendOVRCustomFace. OVRCustomFace+RetargetingType.ARKitBlendshapes: clean, but guarded behind#if OVR_INTERNAL_CODEin the public Oculus Integration package — not 3P-shippable.OVRCustomFace+RetargetingType.Custom+GetCustomBlendShapeNameAndExpressionPairsoverride (this skill): fully public API, shippable, and gets all base-class behavior (data validity gating, weight scaling, mesh assertion).
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/meta-quest/agentic-tools/hz-unity-face-tracking">View hz-unity-face-tracking on skillZs</a>