references/ARKitEyeGazeBlendshapeDriver.cs
// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.
using UnityEngine;
/// <summary>
/// Reads gaze rotation from two OVREyeGaze components (one per eye) and writes it into
/// a SkinnedMeshRenderer's ARKit eyeLookUp/Down/In/Out_L/R blendshapes.
///
/// Why this exists: the eye-related fields on OVRFaceExpressions (EyesLookUpL etc.) are
/// derived from face-camera visuals, not the dedicated eye tracker. On Quest Pro they're
/// often zero or unreliable. OVREyeGaze taps the eye tracker directly and gives a clean
/// per-eye orientation. This component runs in LateUpdate so it overrides the eye-shape
/// weights that OVRCustomFace/ARKitOVRCustomFace just wrote in Update.
///
/// Setup:
/// 1. Create two empty GameObjects, parent them under the head or camera rig.
/// Add OVREyeGaze to each. Set Eye=Left/Right, TrackingMode=HeadSpace.
/// Optionally set ReferenceFrame = your head transform (CenterEyeAnchor works).
/// 2. Add this component to the same GameObject as the head SkinnedMeshRenderer.
/// 3. Wire leftEye, rightEye, and (optionally) referenceFrame in the inspector.
/// 4. Add QuestFacePermissionsRequester to the startup scene — eye tracking won't
/// stream data without the runtime EYE_TRACKING permission request.
/// </summary>
[RequireComponent(typeof(SkinnedMeshRenderer))]
public class ARKitEyeGazeBlendshapeDriver : MonoBehaviour
{
[SerializeField] OVREyeGaze leftEye;
[SerializeField] OVREyeGaze rightEye;
[SerializeField, Tooltip("Reference (head) transform. Gaze rotation is taken relative to this. " +
"Leave empty to use Camera.main.")]
Transform referenceFrame;
[SerializeField, Tooltip("Eye rotation (degrees) at which the corresponding blendshape reaches weight 1.")]
float maxAngleDeg = 30f;
[SerializeField, Tooltip("Prefix on the mesh's blendshape names, e.g. 'blendShape2.'. Leave empty for none.")]
string blendShapePrefix = "blendShape2.";
[SerializeField, Tooltip("Smoothing factor (0 = no smoothing, 1 = freeze).")]
[Range(0f, 0.95f)] float smoothing = 0.4f;
SkinnedMeshRenderer _smr;
int _upL, _downL, _inL, _outL, _upR, _downR, _inR, _outR;
float _upLW, _downLW, _inLW, _outLW, _upRW, _downRW, _inRW, _outRW;
void Awake()
{
_smr = GetComponent<SkinnedMeshRenderer>();
var m = _smr.sharedMesh;
_upL = m.GetBlendShapeIndex(blendShapePrefix + "eyeLookUp_L");
_downL = m.GetBlendShapeIndex(blendShapePrefix + "eyeLookDown_L");
_inL = m.GetBlendShapeIndex(blendShapePrefix + "eyeLookIn_L");
_outL = m.GetBlendShapeIndex(blendShapePrefix + "eyeLookOut_L");
_upR = m.GetBlendShapeIndex(blendShapePrefix + "eyeLookUp_R");
_downR = m.GetBlendShapeIndex(blendShapePrefix + "eyeLookDown_R");
_inR = m.GetBlendShapeIndex(blendShapePrefix + "eyeLookIn_R");
_outR = m.GetBlendShapeIndex(blendShapePrefix + "eyeLookOut_R");
}
void LateUpdate()
{
var refT = referenceFrame != null ? referenceFrame
: (Camera.main != null ? Camera.main.transform : null);
if (refT == null) return;
ApplyEye(leftEye, refT, isLeftEye: true,
_upL, _downL, _inL, _outL,
ref _upLW, ref _downLW, ref _inLW, ref _outLW);
ApplyEye(rightEye, refT, isLeftEye: false,
_upR, _downR, _inR, _outR,
ref _upRW, ref _downRW, ref _inRW, ref _outRW);
}
void ApplyEye(OVREyeGaze gaze, Transform refT, bool isLeftEye,
int up, int down, int inIdx, int outIdx,
ref float upW, ref float downW, ref float inW, ref float outW)
{
if (gaze == null || !gaze.EyeTrackingEnabled) return;
Quaternion rel = Quaternion.Inverse(refT.rotation) * gaze.transform.rotation;
Vector3 e = rel.eulerAngles;
float pitch = Wrap180(e.x); // +down, -up (Unity X-axis rotation: nose toward floor)
float yaw = Wrap180(e.y); // +right, -left
float targetUp = Mathf.Clamp01(-pitch / maxAngleDeg);
float targetDown = Mathf.Clamp01(pitch / maxAngleDeg);
// ARKit: "_In" = toward the nose. Left eye looks right to look in; right eye looks left.
float targetIn = isLeftEye ? Mathf.Clamp01(yaw / maxAngleDeg)
: Mathf.Clamp01(-yaw / maxAngleDeg);
float targetOut = isLeftEye ? Mathf.Clamp01(-yaw / maxAngleDeg)
: Mathf.Clamp01(yaw / maxAngleDeg);
float k = smoothing;
upW = Mathf.Lerp(targetUp, upW, k);
downW = Mathf.Lerp(targetDown, downW, k);
inW = Mathf.Lerp(targetIn, inW, k);
outW = Mathf.Lerp(targetOut, outW, k);
if (up >= 0) _smr.SetBlendShapeWeight(up, upW * 100f);
if (down >= 0) _smr.SetBlendShapeWeight(down, downW * 100f);
if (inIdx >= 0) _smr.SetBlendShapeWeight(inIdx, inW * 100f);
if (outIdx >= 0) _smr.SetBlendShapeWeight(outIdx, outW * 100f);
}
static float Wrap180(float a) => a > 180f ? a - 360f : a;
}
references/ARKitOVRCustomFace.cs
// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// OVRCustomFace subclass that maps ARKit-named blendshapes (camelCase + _L/_R, with an
/// optional prefix like "blendShape2.") to OVR FaceExpressions. Mapping is built directly
/// from the mesh blendshape names in MapBlendshapes() — call via context menu in the
/// inspector, or it runs automatically on Reset and from OnValidate when _mappings is empty.
/// </summary>
public class ARKitOVRCustomFace : OVRCustomFace
{
static readonly (string, OVRFaceExpressions.FaceExpression)[] ARKitTable = new[]
{
("browDown_L", OVRFaceExpressions.FaceExpression.BrowLowererL),
("browDown_R", OVRFaceExpressions.FaceExpression.BrowLowererR),
("browInnerUp", OVRFaceExpressions.FaceExpression.InnerBrowRaiserL),
("browOuterUp_L", OVRFaceExpressions.FaceExpression.OuterBrowRaiserL),
("browOuterUp_R", OVRFaceExpressions.FaceExpression.OuterBrowRaiserR),
("cheekPuff", OVRFaceExpressions.FaceExpression.CheekPuffL),
("cheekSquint_L", OVRFaceExpressions.FaceExpression.CheekRaiserL),
("cheekSquint_R", OVRFaceExpressions.FaceExpression.CheekRaiserR),
("eyeBlink_L", OVRFaceExpressions.FaceExpression.EyesClosedL),
("eyeBlink_R", OVRFaceExpressions.FaceExpression.EyesClosedR),
("eyeLookDown_L", OVRFaceExpressions.FaceExpression.EyesLookDownL),
("eyeLookDown_R", OVRFaceExpressions.FaceExpression.EyesLookDownR),
("eyeLookIn_L", OVRFaceExpressions.FaceExpression.EyesLookRightL),
("eyeLookIn_R", OVRFaceExpressions.FaceExpression.EyesLookLeftR),
("eyeLookOut_L", OVRFaceExpressions.FaceExpression.EyesLookLeftL),
("eyeLookOut_R", OVRFaceExpressions.FaceExpression.EyesLookRightR),
("eyeLookUp_L", OVRFaceExpressions.FaceExpression.EyesLookUpL),
("eyeLookUp_R", OVRFaceExpressions.FaceExpression.EyesLookUpR),
("eyeSquint_L", OVRFaceExpressions.FaceExpression.LidTightenerL),
("eyeSquint_R", OVRFaceExpressions.FaceExpression.LidTightenerR),
("eyeWide_L", OVRFaceExpressions.FaceExpression.UpperLidRaiserL),
("eyeWide_R", OVRFaceExpressions.FaceExpression.UpperLidRaiserR),
("jawForward", OVRFaceExpressions.FaceExpression.JawThrust),
("jawLeft", OVRFaceExpressions.FaceExpression.JawSidewaysLeft),
("jawOpen", OVRFaceExpressions.FaceExpression.JawDrop),
("jawRight", OVRFaceExpressions.FaceExpression.JawSidewaysRight),
("mouthClose", OVRFaceExpressions.FaceExpression.LipsToward),
("mouthDimple_L", OVRFaceExpressions.FaceExpression.DimplerL),
("mouthDimple_R", OVRFaceExpressions.FaceExpression.DimplerR),
("mouthFrown_L", OVRFaceExpressions.FaceExpression.LipCornerDepressorL),
("mouthFrown_R", OVRFaceExpressions.FaceExpression.LipCornerDepressorR),
("mouthFunnel", OVRFaceExpressions.FaceExpression.LipFunnelerLT),
("mouthLeft", OVRFaceExpressions.FaceExpression.MouthLeft),
("mouthLowerDown_L", OVRFaceExpressions.FaceExpression.LowerLipDepressorL),
("mouthLowerDown_R", OVRFaceExpressions.FaceExpression.LowerLipDepressorR),
("mouthPress_L", OVRFaceExpressions.FaceExpression.LipPressorL),
("mouthPress_R", OVRFaceExpressions.FaceExpression.LipPressorR),
("mouthPucker", OVRFaceExpressions.FaceExpression.LipPuckerL),
("mouthRight", OVRFaceExpressions.FaceExpression.MouthRight),
("mouthRollLower", OVRFaceExpressions.FaceExpression.LipSuckLB),
("mouthRollUpper", OVRFaceExpressions.FaceExpression.LipSuckLT),
("mouthShrugLower", OVRFaceExpressions.FaceExpression.ChinRaiserB),
("mouthShrugUpper", OVRFaceExpressions.FaceExpression.ChinRaiserT),
("mouthSmile_L", OVRFaceExpressions.FaceExpression.LipCornerPullerL),
("mouthSmile_R", OVRFaceExpressions.FaceExpression.LipCornerPullerR),
("mouthStretch_L", OVRFaceExpressions.FaceExpression.LipStretcherL),
("mouthStretch_R", OVRFaceExpressions.FaceExpression.LipStretcherR),
("mouthUpperUp_L", OVRFaceExpressions.FaceExpression.UpperLipRaiserL),
("mouthUpperUp_R", OVRFaceExpressions.FaceExpression.UpperLipRaiserR),
("noseSneer_L", OVRFaceExpressions.FaceExpression.NoseWrinklerL),
("noseSneer_R", OVRFaceExpressions.FaceExpression.NoseWrinklerR),
("tongueOut", OVRFaceExpressions.FaceExpression.TongueOut),
};
protected override (string[], OVRFaceExpressions.FaceExpression[])
GetCustomBlendShapeNameAndExpressionPairs()
{
var names = new string[ARKitTable.Length];
var exprs = new OVRFaceExpressions.FaceExpression[ARKitTable.Length];
for (int i = 0; i < ARKitTable.Length; i++)
{
names[i] = ARKitTable[i].Item1;
exprs[i] = ARKitTable[i].Item2;
}
return (names, exprs);
}
[ContextMenu("Map Blendshapes")]
public void MapBlendshapes()
{
var smr = GetComponent<SkinnedMeshRenderer>();
if (smr == null || smr.sharedMesh == null)
{
Debug.LogError($"[ARKitOVRCustomFace] no SkinnedMeshRenderer/mesh on {name}", this);
return;
}
var mesh = smr.sharedMesh;
var lookup = new Dictionary<string, OVRFaceExpressions.FaceExpression>(ARKitTable.Length);
foreach (var (n, e) in ARKitTable) lookup[n.ToLowerInvariant()] = e;
int n2 = mesh.blendShapeCount;
var mappings = new OVRFaceExpressions.FaceExpression[n2];
int matched = 0;
for (int i = 0; i < n2; i++)
{
var raw = mesh.GetBlendShapeName(i);
var dot = raw.LastIndexOf('.');
var key = (dot >= 0 ? raw.Substring(dot + 1) : raw).ToLowerInvariant();
if (lookup.TryGetValue(key, out var fe))
{
mappings[i] = fe;
matched++;
}
else
{
mappings[i] = OVRFaceExpressions.FaceExpression.Max;
}
}
Mappings = mappings;
#if UNITY_EDITOR
UnityEditor.EditorUtility.SetDirty(this);
#endif
Debug.Log($"[ARKitOVRCustomFace] mapped {matched}/{n2} blendshapes on '{mesh.name}'", this);
}
void Reset()
{
MapBlendshapes();
}
void OnValidate()
{
if (Mappings == null || Mappings.Length == 0)
MapBlendshapes();
}
}
SKILL.md
---
name: hz-unity-face-tracking
license: Apache-2.0
description: Drive ARKit-blendshape-rigged head/face models in Unity with the wearer's facial expressions on Meta Quest 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.
---
# 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 Quest. 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 Quest 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 Quest".
- Target device: Quest Pro, Quest 3, Quest 3S (Quest 2 is no-op — no face cameras).
## Prerequisites checklist
1. **Quest face tracking-capable headset** (Pro / 3 / 3S).
2. **Packages** in `Packages/manifest.json`:
- `com.meta.xr.sdk.core` (Meta XR Core — provides `OVRFaceExpressions`, `OVRCustomFace`)
- `com.meta.xr.sdk.movement` (Meta Movement SDK — A2E + retargeting helpers)
3. **`Assets/Oculus/OculusProjectConfig.asset`** (verify via Project Settings → Meta XR):
- `faceTrackingSupport: 1` (Supported) or `2` (Required)
- `eyeTrackingSupport: 1` if the rig has gaze
4. **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`
5. **OVRCameraRig** in the scene with `OVRManager.FaceTrackingDataSources` including `Audio` (A2E) — if you skip Audio, mouth motion is visual-only.
After any change to OculusProjectConfig, call `meta_update_android_manifest` to regenerate the manifest.
## Approach (high level)
1. Drop the **`ARKitOVRCustomFace`** script (in `references/ARKitOVRCustomFace.cs`) into the project.
2. Add an `OVRFaceExpressions` component to the OVRCameraRig (or anywhere in the scene).
3. On the GameObject that has the model's `SkinnedMeshRenderer`, add `ARKitOVRCustomFace`. Adding the component triggers `Reset()`, which auto-populates the blendshape→FaceExpression mapping by scanning the mesh's blendshape names.
4. Wire the component's `FaceExpressions` field to the `OVRFaceExpressions` instance.
5. For eye gaze: drop **`ARKitEyeGazeBlendshapeDriver`** in (see [Eye tracking](#eye-tracking-do-this--face-expressions-alone-dont-work) below).
6. 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 `OVREyeGaze` components (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 whatever `ARKitOVRCustomFace` wrote in `Update`.
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)
1. Create two empty GameObjects under `CenterEyeAnchor` (or your head transform): `LeftEyeGaze`, `RightEyeGaze`.
2. Add `OVREyeGaze` to each. Set `Eye = Left` / `Right`, `TrackingMode = HeadSpace`, `ApplyPosition = false`, `ApplyRotation = true`, `ConfidenceThreshold = 0.5`.
3. Add `ARKitEyeGazeBlendshapeDriver` to the head's SkinnedMeshRenderer GameObject (alongside `ARKitOVRCustomFace`). Wire `leftEye`, `rightEye`, and `referenceFrame` (= `CenterEyeAnchor`).
4. Tweak `maxAngleDeg` (default 30°) and `smoothing` (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 manifest:
- Unity MCP: `meta_update_android_manifest`
- Or Editor menu: **Meta → Tools → Update AndroidManifest.xml**
### 3. Scene setup
```
Scene Hierarchy
├── OVRCameraRig (from meta_add_camerarig)
│ └── (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 `SkinnedMeshRenderer`s (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:
```csharp
go.GetComponent<ARKitOVRCustomFace>().MapBlendshapes();
```
From Unity MCP `Unity_RunCommand`, the cleanest invocation (the OVR/MSDK types aren't referenced in the MCP dynamic assembly, so use SendMessage to avoid reflection):
```csharp
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.
- **On device**, build APK, side-load, grant Face Tracking + Microphone permissions on first launch.
- **Check the mapping** at edit time: inspect the `ARKitOVRCustomFace` component. `Mappings.Length` should equal `SkinnedMeshRenderer.sharedMesh.blendShapeCount`. The Console log from `MapBlendshapes()` reports `mapped X/N blendshapes` — X should be 50 (or 52 if the FBX has all of them).
## 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 custom `MapBlendshapes` (use `SlothARKitFaceDriver`-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/Out`** are 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 file
- `references/ARKitOVRCustomFace.cs` — drop-in `OVRCustomFace` subclass
- `references/ARKitEyeGazeBlendshapeDriver.cs` — OVREyeGaze → `eyeLook*` blendshape driver (LateUpdate)
## Why this approach (vs. alternatives)
- **Custom `MonoBehaviour` driver** that reads `OVRFaceExpressions[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 extend `OVRCustomFace`.
- **`OVRCustomFace` + `RetargetingType.ARKitBlendshapes`**: clean, but guarded behind `#if OVR_INTERNAL_CODE` in the public Oculus Integration package — **not 3P-shippable**.
- **`OVRCustomFace` + `RetargetingType.Custom` + `GetCustomBlendShapeNameAndExpressionPairs` override** (this skill): fully public API, shippable, and gets all base-class behavior (data validity gating, weight scaling, mesh assertion).