using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace Streamingle.Gaze.Editor
{
///
/// Guided calibration UI for authoring the five cardinal gaze poses and optionally
/// refining the generated 3x3 grid. Preview writes are owned by a disposable session
/// so opening the tool never permanently changes the face renderer.
///
public sealed class GazeCalibrationWindow : EditorWindow
{
private const float PadMinimumSize = 230f;
private const float PadMaximumSize = 380f;
private const float MarkerSize = 32f;
private const float MarkerHitRadius = 23f;
private static readonly GazeCalibrationPoint[] BasicPoints =
{
GazeCalibrationPoint.Center,
GazeCalibrationPoint.CenterLeft,
GazeCalibrationPoint.CenterRight,
GazeCalibrationPoint.UpCenter,
GazeCalibrationPoint.DownCenter
};
private static readonly GazeCalibrationPoint[] AllPoints =
{
GazeCalibrationPoint.DownLeft,
GazeCalibrationPoint.DownCenter,
GazeCalibrationPoint.DownRight,
GazeCalibrationPoint.CenterLeft,
GazeCalibrationPoint.Center,
GazeCalibrationPoint.CenterRight,
GazeCalibrationPoint.UpLeft,
GazeCalibrationPoint.UpCenter,
GazeCalibrationPoint.UpRight
};
private GazeCalibrationPreviewSession previewSession;
[SerializeField] private BlendshapeGazeDriver driver;
[SerializeField] private GazeCalibrationPoint selectedPoint = GazeCalibrationPoint.Center;
private Vector2 scrollPosition;
[SerializeField] private bool advancedGrid;
[SerializeField] private bool liveInterpolation = true;
private float[] draftWeights = Array.Empty();
private GazeCalibrationPoint draftPoint;
private string draftLayout;
private bool hasDraft;
private BlendshapeGazeProfile calibrationProfile;
private BlendshapeGazeProfile workingProfile;
private bool isFinishingCalibration;
private float[] copiedWeights;
private string copiedLayout;
private string feedback;
private MessageType feedbackType = MessageType.Info;
[MenuItem("Tools/Streamingle Utilities/Gaze/Calibration")]
private static void OpenFromMenu()
{
var selectedDriver = Selection.activeGameObject != null
? Selection.activeGameObject.GetComponentInParent()
: Selection.activeObject as BlendshapeGazeDriver;
Open(selectedDriver);
}
public static void Open(BlendshapeGazeDriver driver)
{
var window = GetWindow();
window.titleContent = new GUIContent("Gaze Calibration");
window.minSize = new Vector2(430f, 600f);
window.SetDriver(driver);
window.Show();
window.Focus();
}
private GazeCalibrationPreviewSession Session
{
get
{
if (previewSession == null)
{
previewSession = new GazeCalibrationPreviewSession();
previewSession.Stopped += OnPreviewSessionStopped;
}
return previewSession;
}
}
private bool IsCalibrating => previewSession != null && previewSession.IsActive;
private void OnEnable()
{
titleContent = new GUIContent("Gaze Calibration");
minSize = new Vector2(430f, 600f);
Undo.undoRedoPerformed += OnUndoRedo;
}
private void OnDisable()
{
Undo.undoRedoPerformed -= OnUndoRedo;
FinishCalibration(false, false);
if (previewSession != null)
{
previewSession.Stopped -= OnPreviewSessionStopped;
previewSession.Dispose();
}
previewSession = null;
}
private void OnGUI()
{
// A session can stop itself on play-mode changes or invalid mappings.
// Treat that as Cancel so a broken/closed preview never commits a draft.
if (!IsCalibrating && workingProfile != null)
FinishCalibration(false, false);
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
DrawHeader();
EditorGUILayout.Space(6f);
DrawDriverField();
EditorGUILayout.Space(6f);
DrawSetup();
EditorGUILayout.Space(8f);
DrawCalibrationControls();
if (IsCalibrating)
{
EditorGUILayout.Space(10f);
DrawDirectionLimits();
EditorGUILayout.Space(10f);
DrawPoseTools();
EditorGUILayout.Space(8f);
DrawCalibrationPad();
EditorGUILayout.Space(10f);
DrawSelectedPoseEditor();
}
if (!string.IsNullOrEmpty(feedback))
{
EditorGUILayout.Space(8f);
EditorGUILayout.HelpBox(feedback, feedbackType);
}
EditorGUILayout.EndScrollView();
}
private void DrawHeader()
{
EditorGUILayout.LabelField("Blendshape Gaze Calibration", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"Tune Center, Left, Right, Up and Down directly here. Generate the four corners, " +
"then use the pad to inspect interpolation without editing the renderer inspector. " +
"Direction labels use the character's perspective.",
MessageType.Info);
}
private void DrawDriverField()
{
using (new EditorGUI.DisabledScope(IsCalibrating))
{
var nextDriver = (BlendshapeGazeDriver)EditorGUILayout.ObjectField(
"Gaze Driver",
driver,
typeof(BlendshapeGazeDriver),
true);
if (nextDriver != driver)
SetDriver(nextDriver);
}
if (driver == null)
EditorGUILayout.HelpBox("Assign a Blendshape Gaze Driver to begin.", MessageType.Warning);
}
private void DrawSetup()
{
EditorGUILayout.LabelField("1. Setup", EditorStyles.boldLabel);
if (driver == null)
return;
using (new EditorGUI.DisabledScope(IsCalibrating))
{
var nextRenderer = (SkinnedMeshRenderer)EditorGUILayout.ObjectField(
"Target Renderer",
driver.TargetRenderer,
typeof(SkinnedMeshRenderer),
true);
if (nextRenderer != driver.TargetRenderer)
AssignRenderer(nextRenderer);
var nextProfile = (BlendshapeGazeProfile)EditorGUILayout.ObjectField(
"Calibration Profile",
driver.Profile,
typeof(BlendshapeGazeProfile),
false);
if (nextProfile != driver.Profile)
AssignProfile(nextProfile);
var nextOrigin = (Transform)EditorGUILayout.ObjectField(
"Gaze Origin",
driver.GazeOrigin,
typeof(Transform),
true);
if (nextOrigin != driver.GazeOrigin)
AssignOrigin(nextOrigin);
}
DrawSetupChecklist();
using (new EditorGUI.DisabledScope(IsCalibrating))
{
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUI.DisabledScope(driver.Profile != null))
{
if (GUILayout.Button("Create Profile"))
CreateProfile();
}
using (new EditorGUI.DisabledScope(
driver.TargetRenderer == null
|| driver.TargetRenderer.sharedMesh == null
|| driver.Profile == null))
{
if (GUILayout.Button("Sync ARKit EyeLook 8"))
SyncEyeLookChannels();
}
}
using (new EditorGUI.DisabledScope(driver.Profile == null))
{
if (GUILayout.Button("Edit Profile Channels Manually"))
{
Selection.activeObject = driver.Profile;
EditorGUIUtility.PingObject(driver.Profile);
SetFeedback(
"Edit Channels in the selected profile Inspector, then return here to calibrate.",
MessageType.Info);
}
}
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUI.DisabledScope(driver.TargetRenderer == null))
{
if (GUILayout.Button(driver.GazeOrigin == null
? "Auto Create Gaze Origin"
: "Recalculate Gaze Origin"))
AutoCreateOrRecalculateOrigin();
}
using (new EditorGUI.DisabledScope(driver.GazeOrigin == null))
{
if (GUILayout.Button("Align Origin Forward to Scene Camera"))
AlignOriginForwardToSceneCamera();
}
}
}
if (driver.HeadReference != null)
{
EditorGUILayout.HelpBox(
"Gaze Origin rotation is the runtime direction basis. Head Reference is used only as a fallback " +
"when Gaze Origin is not assigned.",
MessageType.Info);
}
}
private void DrawSetupChecklist()
{
var renderer = driver.TargetRenderer;
var profile = driver.Profile;
var hasRendererMesh = renderer != null && renderer.sharedMesh != null;
var sourceMeshMatches = profile == null
|| profile.SourceMesh == null
|| (hasRendererMesh && profile.SourceMesh == renderer.sharedMesh);
DrawCheck("Renderer and mesh", hasRendererMesh && sourceMeshMatches);
DrawCheck("Calibration profile", profile != null);
DrawCheck("Gaze origin", driver.GazeOrigin != null);
if (hasRendererMesh && profile != null && !sourceMeshMatches)
{
EditorGUILayout.HelpBox(
$"Profile mesh '{profile.SourceMesh.name}' does not match renderer mesh " +
$"'{renderer.sharedMesh.name}'. Sync the intended Face mesh before calibration.",
MessageType.Error);
}
var configured = profile != null ? profile.Channels.Count : 0;
var mapped = 0;
var missing = new List();
if (renderer != null && renderer.sharedMesh != null && profile != null)
{
for (var index = 0; index < profile.Channels.Count; index++)
{
var channel = profile.Channels[index];
if (channel != null && profile.FindBlendShapeIndex(renderer.sharedMesh, index) >= 0)
mapped++;
else
missing.Add(channel != null ? channel.BlendShapeName : $"Channel {index + 1}");
}
}
DrawCheck($"Channels mapped ({mapped}/{configured})", configured > 0 && mapped == configured);
if (missing.Count > 0)
{
EditorGUILayout.HelpBox(
"Missing on renderer: " + string.Join(", ", missing),
MessageType.Warning);
}
}
private static void DrawCheck(string label, bool complete)
{
var previous = GUI.color;
GUI.color = complete ? new Color(0.55f, 0.95f, 0.6f) : new Color(1f, 0.68f, 0.45f);
EditorGUILayout.LabelField(complete ? "\u2713 " + label : "\u25cb " + label);
GUI.color = previous;
}
private void DrawCalibrationControls()
{
EditorGUILayout.LabelField("2. Calibration", EditorStyles.boldLabel);
if (!IsCalibrating)
{
var canStart = CanStartCalibration(out var reason);
using (new EditorGUI.DisabledScope(!canStart))
{
if (GUILayout.Button("Start Calibration", GUILayout.Height(34f)))
StartCalibration();
}
if (driver != null && !canStart && !string.IsNullOrEmpty(reason))
EditorGUILayout.HelpBox(reason, MessageType.Warning);
}
else
{
var profile = Session.Profile;
var cardinalCount = CountCaptured(profile, BasicPoints);
var totalCount = CountCaptured(profile, AllPoints);
var fullyCalibrated = profile != null && profile.IsFullyCalibrated;
EditorGUILayout.HelpBox(
$"Cardinal poses: {cardinalCount}/5 Full grid: {totalCount}/9" +
(fullyCalibrated
? "\nReady for runtime interpolation."
: "\nYou can save a draft now, but runtime gaze stays inactive until 9/9."),
fullyCalibrated ? MessageType.Info : MessageType.Warning);
using (new EditorGUILayout.HorizontalScope())
{
var previous = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.55f, 0.85f, 0.65f);
var applyLabel = fullyCalibrated
? "Apply & Finish"
: "Save Draft & Finish";
if (GUILayout.Button(applyLabel, GUILayout.Height(34f)))
{
GUI.backgroundColor = previous;
FinishCalibration(true, true);
return;
}
GUI.backgroundColor = previous;
if (GUILayout.Button("Cancel & Revert", GUILayout.Height(34f)))
{
FinishCalibration(false, true);
return;
}
}
EditorGUILayout.HelpBox(
"Apply saves profile edits. Cancel or closing this window restores both the profile snapshot " +
"and the face pose from when calibration started.",
MessageType.None);
}
}
private void DrawDirectionLimits()
{
var profile = Session.Profile;
if (profile == null)
return;
EditorGUILayout.LabelField("3. Asymmetric View Limits", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"Angles outside these limits clamp to the closest edge pose. Values are in character-local degrees; " +
"Left/Right are named from the character's perspective.",
MessageType.None);
var left = profile.MinYaw;
var right = profile.MaxYaw;
var down = profile.MinPitch;
var up = profile.MaxPitch;
EditorGUI.BeginChangeCheck();
using (new EditorGUILayout.HorizontalScope())
{
left = EditorGUILayout.Slider(new GUIContent("Left Yaw", "Negative yaw limit."), left, -90f, -0.1f);
right = EditorGUILayout.Slider(new GUIContent("Right Yaw", "Positive yaw limit."), right, 0.1f, 90f);
}
using (new EditorGUILayout.HorizontalScope())
{
down = EditorGUILayout.Slider(new GUIContent("Down Pitch", "Negative pitch limit."), down, -90f, -0.1f);
up = EditorGUILayout.Slider(new GUIContent("Up Pitch", "Positive pitch limit."), up, 0.1f, 90f);
}
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(profile, "Change Gaze View Limits");
profile.SetDirectionLimits(left, right, down, up);
MarkProfileDirty();
}
}
private void DrawPoseTools()
{
var profile = Session.Profile;
if (profile == null)
return;
EditorGUILayout.LabelField("4. Pose Tools", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Auto Seed ARKit"))
SeedStandardEyeLook();
using (new EditorGUI.DisabledScope(!profile.CanGenerateCorners))
{
if (GUILayout.Button("Generate Corners"))
GenerateCorners();
}
}
var opposite = GetHorizontalOpposite(selectedPoint);
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUI.DisabledScope(!profile.IsSampleCaptured(selectedPoint)))
{
var mirrorLabel = opposite == selectedPoint
? "Swap Left/Right Values"
: $"Mirror to {GetPointLabel(opposite)}";
if (GUILayout.Button(mirrorLabel))
MirrorSelected(opposite);
}
using (new EditorGUI.DisabledScope(
selectedPoint != GazeCalibrationPoint.Center
&& !profile.IsSampleCaptured(GazeCalibrationPoint.Center)))
{
var resetLabel = selectedPoint == GazeCalibrationPoint.Center
? "Reset Center to Session Start"
: "Reset Pose to Center";
if (GUILayout.Button(resetLabel))
ResetSelectedToCenter();
}
}
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUI.DisabledScope(!profile.IsSampleCaptured(selectedPoint)))
{
if (GUILayout.Button("Copy Pose"))
CopySelected();
}
var canPaste = copiedWeights != null
&& copiedWeights.Length == profile.Channels.Count
&& copiedLayout == GetChannelLayout(profile);
using (new EditorGUI.DisabledScope(!canPaste))
{
if (GUILayout.Button("Paste Pose"))
PasteSelected();
}
}
}
private void DrawCalibrationPad()
{
var profile = Session.Profile;
if (profile == null)
return;
EditorGUILayout.LabelField("5. Gaze Pad", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
var nextAdvanced = EditorGUILayout.ToggleLeft(
"Advanced 3x3",
advancedGrid,
GUILayout.Width(125f));
if (advancedGrid && !nextAdvanced && !ContainsPoint(BasicPoints, selectedPoint))
SelectPoint(GazeCalibrationPoint.Center);
advancedGrid = nextAdvanced;
liveInterpolation = EditorGUILayout.ToggleLeft(
"Live Interpolation",
liveInterpolation,
GUILayout.Width(145f));
}
if (liveInterpolation && !profile.IsFullyCalibrated)
{
EditorGUILayout.HelpBox(
"Live interpolation becomes available after all nine points exist. Tune five points, then Generate Corners.",
MessageType.None);
}
var size = Mathf.Clamp(position.width - 72f, PadMinimumSize, PadMaximumSize);
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.FlexibleSpace();
var padRect = GUILayoutUtility.GetRect(size, size, GUILayout.Width(size), GUILayout.Height(size));
GUILayout.FlexibleSpace();
DrawPadBackground(padRect);
HandlePadInput(padRect, advancedGrid ? AllPoints : BasicPoints);
DrawPadMarkers(padRect, advancedGrid ? AllPoints : BasicPoints);
}
EditorGUILayout.LabelField(
$"Selected: {GetPointLabel(selectedPoint)}" +
(!profile.IsSampleCaptured(selectedPoint)
? " | Draft (move a slider to save this pose)"
: string.Empty) +
(Session.IsFreePreview
? $" | Live: ({Session.PreviewCoordinates.x:0.00}, {Session.PreviewCoordinates.y:0.00})"
: string.Empty),
EditorStyles.centeredGreyMiniLabel);
}
private static void DrawPadBackground(Rect rect)
{
EditorGUI.DrawRect(rect, new Color(0.105f, 0.115f, 0.13f));
EditorGUI.DrawRect(new Rect(rect.x + rect.width * 0.5f, rect.y, 1f, rect.height), new Color(0.35f, 0.38f, 0.42f));
EditorGUI.DrawRect(new Rect(rect.x, rect.y + rect.height * 0.5f, rect.width, 1f), new Color(0.35f, 0.38f, 0.42f));
EditorGUI.DrawRect(new Rect(rect.x, rect.y, rect.width, 1f), new Color(0.45f, 0.48f, 0.52f));
EditorGUI.DrawRect(new Rect(rect.x, rect.yMax - 1f, rect.width, 1f), new Color(0.45f, 0.48f, 0.52f));
EditorGUI.DrawRect(new Rect(rect.x, rect.y, 1f, rect.height), new Color(0.45f, 0.48f, 0.52f));
EditorGUI.DrawRect(new Rect(rect.xMax - 1f, rect.y, 1f, rect.height), new Color(0.45f, 0.48f, 0.52f));
}
private void DrawPadMarkers(Rect rect, IReadOnlyList visiblePoints)
{
var profile = Session.Profile;
var markerStyle = new GUIStyle(EditorStyles.miniButton)
{
alignment = TextAnchor.MiddleCenter,
fontStyle = FontStyle.Bold
};
for (var index = 0; index < visiblePoints.Count; index++)
{
var point = visiblePoints[index];
var center = PointToPad(rect, CoordinatesFor(point));
var marker = new Rect(
center.x - MarkerSize * 0.5f,
center.y - MarkerSize * 0.5f,
MarkerSize,
MarkerSize);
var previous = GUI.backgroundColor;
if (point == selectedPoint)
GUI.backgroundColor = new Color(0.35f, 0.72f, 1f);
else if (profile.IsSampleCaptured(point))
GUI.backgroundColor = new Color(0.45f, 0.86f, 0.5f);
else
GUI.backgroundColor = new Color(1f, 0.65f, 0.3f);
GUI.Label(marker, GetPointGlyph(point), markerStyle);
GUI.backgroundColor = previous;
}
if (Session.IsFreePreview)
{
var live = PointToPad(rect, Session.PreviewCoordinates);
EditorGUI.DrawRect(new Rect(live.x - 3f, live.y - 3f, 6f, 6f), Color.cyan);
}
}
private void HandlePadInput(Rect rect, IReadOnlyList visiblePoints)
{
var current = Event.current;
var controlId = GUIUtility.GetControlID("StreamingleGazeCalibrationPad".GetHashCode(), FocusType.Passive, rect);
if (current.type == EventType.MouseDown && current.button == 0 && rect.Contains(current.mousePosition))
{
var closest = FindClosestPoint(rect, current.mousePosition, visiblePoints, out var distance);
if (distance <= MarkerHitRadius || !liveInterpolation || !Session.Profile.IsFullyCalibrated)
{
SelectPoint(closest);
}
else
{
GUIUtility.hotControl = controlId;
PreviewPadPosition(rect, current.mousePosition);
}
current.Use();
}
else if (current.type == EventType.MouseDrag && GUIUtility.hotControl == controlId)
{
PreviewPadPosition(rect, current.mousePosition);
current.Use();
}
else if (current.type == EventType.MouseUp && GUIUtility.hotControl == controlId)
{
PreviewPadPosition(rect, current.mousePosition);
GUIUtility.hotControl = 0;
current.Use();
}
}
private void PreviewPadPosition(Rect rect, Vector2 mousePosition)
{
if (!liveInterpolation || !Session.Profile.IsFullyCalibrated)
return;
var normalized = new Vector2(
Mathf.Clamp((mousePosition.x - rect.x) / rect.width * 2f - 1f, -1f, 1f),
Mathf.Clamp((rect.yMax - mousePosition.y) / rect.height * 2f - 1f, -1f, 1f));
Session.PreviewNormalized(normalized);
SceneView.RepaintAll();
Repaint();
}
private void DrawSelectedPoseEditor()
{
var profile = Session.Profile;
if (profile == null || profile.Channels.Count == 0)
return;
EnsureDraftLoaded();
if (draftWeights.Length != profile.Channels.Count)
return;
EditorGUILayout.LabelField($"6. {GetPointLabel(selectedPoint)} Pose", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
profile.IsSampleCaptured(selectedPoint)
? "The face preview updates immediately. Apply & Finish keeps this pose."
: "This is a temporary draft copied from Center. Moving any slider saves this point; " +
"selecting it alone does not mark it complete.",
profile.IsSampleCaptured(selectedPoint) ? MessageType.None : MessageType.Info);
EditorGUI.BeginChangeCheck();
DrawChannelGroup("Left Eye", ChannelSide.Left, profile, draftWeights);
DrawChannelGroup("Right Eye", ChannelSide.Right, profile, draftWeights);
DrawChannelGroup("Other / Correctives", ChannelSide.Other, profile, draftWeights);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(profile, "Edit Gaze Calibration Pose");
profile.SetSample(selectedPoint, draftWeights);
MarkProfileDirty();
Session.PreviewWeights(draftWeights, selectedPoint);
SceneView.RepaintAll();
}
}
private static void DrawChannelGroup(
string title,
ChannelSide side,
BlendshapeGazeProfile profile,
float[] weights)
{
var hasAny = false;
for (var index = 0; index < profile.Channels.Count; index++)
{
var channel = profile.Channels[index];
if (GetChannelSide(channel != null ? channel.BlendShapeName : null) == side)
{
hasAny = true;
break;
}
}
if (!hasAny)
return;
EditorGUILayout.Space(3f);
EditorGUILayout.LabelField(title, EditorStyles.miniBoldLabel);
for (var index = 0; index < profile.Channels.Count; index++)
{
var channel = profile.Channels[index];
if (channel == null || GetChannelSide(channel.BlendShapeName) != side)
continue;
var tooltip = channel.Usage == GazeChannelUsage.Corrective
? $"Corrective ({channel.CorrectiveBlendMode})"
: "EyeLook override";
var minimum = channel.Usage == GazeChannelUsage.Corrective ? -100f : 0f;
weights[index] = EditorGUILayout.Slider(
new GUIContent(channel.BlendShapeName, tooltip),
weights[index],
minimum,
100f);
}
}
private void StartCalibration()
{
if (!CanStartCalibration(out var reason))
{
SetFeedback(reason, MessageType.Error);
return;
}
var originalProfile = driver.Profile;
var editableProfile = Instantiate(originalProfile);
editableProfile.name = originalProfile.name;
editableProfile.hideFlags = HideFlags.HideAndDontSave;
if (!Session.Start(driver, editableProfile, out var error))
{
DestroyImmediate(editableProfile);
SetFeedback(error, MessageType.Error);
return;
}
calibrationProfile = originalProfile;
workingProfile = editableProfile;
selectedPoint = GazeCalibrationPoint.Center;
hasDraft = false;
LoadDraftForSelectedPoint();
PreviewDraft();
SetFeedback(
"Calibration started. Tune Center first, then the four cardinal poses.",
MessageType.Info);
SceneView.RepaintAll();
}
private void FinishCalibration(bool applyChanges, bool showFeedback)
{
if (isFinishingCalibration)
return;
isFinishingCalibration = true;
var originalProfile = calibrationProfile != null
? calibrationProfile
: driver != null ? driver.Profile : null;
var editableProfile = workingProfile != null
? workingProfile
: previewSession != null ? previewSession.Profile : null;
try
{
if (applyChanges && originalProfile != null && editableProfile != null
&& originalProfile != editableProfile)
{
Undo.RecordObject(originalProfile, "Apply Gaze Calibration");
EditorUtility.CopySerializedManagedFieldsOnly(editableProfile, originalProfile);
EditorUtility.SetDirty(originalProfile);
}
// Keep the driver override until the original renderer pose has
// been restored and any working-copy data has been committed.
previewSession?.Stop();
if (applyChanges && originalProfile != null)
{
AssetDatabase.SaveAssetIfDirty(originalProfile);
driver?.RebuildCache();
}
}
finally
{
previewSession?.Stop();
if (editableProfile != null && editableProfile != originalProfile)
{
Undo.ClearUndo(editableProfile);
DestroyImmediate(editableProfile);
}
calibrationProfile = null;
workingProfile = null;
hasDraft = false;
draftWeights = Array.Empty();
draftLayout = null;
isFinishingCalibration = false;
SceneView.RepaintAll();
}
if (showFeedback)
{
SetFeedback(
applyChanges
? "Calibration changes were saved. The original face pose was restored."
: "Calibration changes were reverted. The original face pose was restored.",
MessageType.Info);
}
}
private void OnPreviewSessionStopped()
{
if (isFinishingCalibration || workingProfile == null)
return;
FinishCalibration(false, false);
Repaint();
}
private void EnsureDraftLoaded()
{
if (!IsCalibrating || Session.Profile == null)
return;
var layout = GetChannelLayout(Session.Profile);
if (hasDraft
&& draftPoint == selectedPoint
&& draftWeights.Length == Session.Profile.Channels.Count
&& string.Equals(draftLayout, layout, StringComparison.Ordinal))
return;
LoadDraftForSelectedPoint();
}
private void LoadDraftForSelectedPoint()
{
if (!IsCalibrating || Session.Profile == null)
return;
var profile = Session.Profile;
if (profile.IsSampleCaptured(selectedPoint))
{
draftWeights = profile.GetSampleWeightsCopy(selectedPoint);
}
else if (selectedPoint != GazeCalibrationPoint.Center
&& profile.IsSampleCaptured(GazeCalibrationPoint.Center))
{
draftWeights = profile.GetSampleWeightsCopy(GazeCalibrationPoint.Center);
}
else
{
draftWeights = Session.GetOriginalWeightsCopy();
}
if (draftWeights.Length != profile.Channels.Count)
draftWeights = new float[profile.Channels.Count];
draftPoint = selectedPoint;
draftLayout = GetChannelLayout(profile);
hasDraft = true;
}
private void PreviewDraft()
{
if (!IsCalibrating)
return;
EnsureDraftLoaded();
if (hasDraft)
Session.PreviewWeights(draftWeights, selectedPoint);
}
private void ReloadDraftAndPreview()
{
hasDraft = false;
LoadDraftForSelectedPoint();
PreviewDraft();
SceneView.RepaintAll();
Repaint();
}
private void SelectPoint(GazeCalibrationPoint point)
{
selectedPoint = point;
hasDraft = false;
LoadDraftForSelectedPoint();
PreviewDraft();
SceneView.RepaintAll();
Repaint();
}
private void SeedStandardEyeLook()
{
var profile = Session.Profile;
if (HasAnyCapturedSamples(profile)
&& !EditorUtility.DisplayDialog(
"Replace Calibration Poses?",
"Auto Seed ARKit replaces all nine calibration poses with standard 0/100 values.",
"Replace",
"Cancel"))
return;
Undo.RecordObject(profile, "Auto Seed ARKit Gaze Poses");
if (!profile.SeedStandardEyeLook())
{
SetFeedback("Auto Seed requires all eight standard ARKit eyeLook channels.", MessageType.Error);
return;
}
MarkProfileDirty();
ReloadDraftAndPreview();
SetFeedback("Seeded five cardinal poses and generated four corners.", MessageType.Info);
}
private void GenerateCorners()
{
var profile = Session.Profile;
var hasExistingCorner = profile.IsSampleCaptured(GazeCalibrationPoint.DownLeft)
|| profile.IsSampleCaptured(GazeCalibrationPoint.DownRight)
|| profile.IsSampleCaptured(GazeCalibrationPoint.UpLeft)
|| profile.IsSampleCaptured(GazeCalibrationPoint.UpRight);
if (hasExistingCorner
&& !EditorUtility.DisplayDialog(
"Regenerate Corners?",
"This replaces all four corner poses using Center + horizontal delta + vertical delta.",
"Regenerate",
"Cancel"))
return;
Undo.RecordObject(profile, "Generate Gaze Corner Poses");
if (!profile.GenerateCornersFromCardinals())
{
SetFeedback("Center, Left, Right, Up and Down must be completed first.", MessageType.Warning);
return;
}
MarkProfileDirty();
ReloadDraftAndPreview();
SetFeedback("Generated four corner poses. Enable Advanced 3x3 to refine them.", MessageType.Info);
}
private void MirrorSelected(GazeCalibrationPoint destination)
{
var profile = Session.Profile;
if (destination != selectedPoint && profile.IsSampleCaptured(destination)
&& !EditorUtility.DisplayDialog(
"Replace Mirrored Pose?",
$"{GetPointLabel(destination)} already contains a pose.",
"Replace",
"Cancel"))
return;
Undo.RecordObject(profile, "Mirror Gaze Calibration Pose");
if (!profile.CopySample(selectedPoint, destination, true))
return;
MarkProfileDirty();
SelectPoint(destination);
}
private void ResetSelectedToCenter()
{
var profile = Session.Profile;
float[] values;
if (selectedPoint == GazeCalibrationPoint.Center)
values = Session.GetOriginalWeightsCopy();
else
values = profile.GetSampleWeightsCopy(GazeCalibrationPoint.Center);
if (values.Length != profile.Channels.Count)
return;
Undo.RecordObject(profile, "Reset Gaze Pose to Center");
profile.SetSample(selectedPoint, values);
MarkProfileDirty();
ReloadDraftAndPreview();
}
private void CopySelected()
{
copiedWeights = Session.Profile.GetSampleWeightsCopy(selectedPoint);
copiedLayout = GetChannelLayout(Session.Profile);
SetFeedback($"Copied {GetPointLabel(selectedPoint)} pose.", MessageType.Info);
}
private void PasteSelected()
{
var profile = Session.Profile;
if (copiedWeights == null || copiedWeights.Length != profile.Channels.Count
|| copiedLayout != GetChannelLayout(profile))
return;
Undo.RecordObject(profile, "Paste Gaze Calibration Pose");
profile.SetSample(selectedPoint, (float[])copiedWeights.Clone());
MarkProfileDirty();
ReloadDraftAndPreview();
}
private void CreateProfile()
{
if (driver == null)
return;
var defaultName = driver.gameObject.name + "_GazeProfile";
var path = EditorUtility.SaveFilePanelInProject(
"Create Gaze Calibration Profile",
defaultName,
"asset",
"Choose where to create the gaze calibration profile.");
if (string.IsNullOrWhiteSpace(path))
return;
var profile = CreateInstance();
profile.name = System.IO.Path.GetFileNameWithoutExtension(path);
AssetDatabase.CreateAsset(profile, path);
Undo.RegisterCreatedObjectUndo(profile, "Create Gaze Calibration Profile");
AssignProfile(profile);
EditorGUIUtility.PingObject(profile);
SetFeedback("Created and assigned a calibration profile.", MessageType.Info);
}
private void SyncEyeLookChannels()
{
var profile = driver.Profile;
var renderer = driver.TargetRenderer;
if (profile == null || renderer == null)
return;
if (HasAnyCapturedSamples(profile)
&& !EditorUtility.DisplayDialog(
"Resync EyeLook Channels?",
"Changing the channel layout invalidates existing calibration poses.",
"Sync and Invalidate",
"Cancel"))
return;
Undo.RecordObject(profile, "Sync ARKit EyeLook Channels");
if (!profile.SyncEyeLookChannels(renderer))
{
SetFeedback(
"The renderer must contain all eight ARKit eyeLook blendshapes (prefixes are allowed).",
MessageType.Error);
return;
}
EditorUtility.SetDirty(profile);
driver.RebuildCache();
SetFeedback("Mapped all eight ARKit eyeLook channels.", MessageType.Info);
}
private void AutoCreateOrRecalculateOrigin()
{
var renderer = driver.TargetRenderer;
if (renderer == null)
return;
if (EditorUtility.IsPersistent(driver) || EditorUtility.IsPersistent(renderer))
{
SetFeedback(
"Open the character in Prefab Mode or use a scene instance before creating Gaze Origin.",
MessageType.Error);
return;
}
var estimate = GazeOriginEstimator.Estimate(renderer, GetConfiguredChannelNames(driver.Profile));
var origin = driver.GazeOrigin;
var parent = ResolveOriginParent(renderer);
if (parent == null && origin != null)
parent = origin.parent;
if (parent == null)
{
SetFeedback(
"Could not find the character Head. Assign Head Reference, then create Gaze Origin again.",
MessageType.Error);
return;
}
var created = false;
if (origin == null)
{
origin = parent.Find("Face_GazeOrigin");
if (origin == null)
{
var originObject = new GameObject("Face_GazeOrigin");
Undo.RegisterCreatedObjectUndo(originObject, "Create Gaze Origin");
origin = originObject.transform;
created = true;
}
Undo.RecordObject(driver, "Assign Gaze Origin");
driver.GazeOrigin = origin;
EditorUtility.SetDirty(driver);
PrefabUtility.RecordPrefabInstancePropertyModifications(driver);
}
if (!created)
Undo.RecordObject(origin, "Recalculate Gaze Origin");
if (origin.parent != parent)
Undo.SetTransformParent(origin, parent, "Parent Gaze Origin Under Head");
if (created)
origin.rotation = parent.rotation;
origin.position = estimate.WorldPosition;
EditorUtility.SetDirty(origin);
PrefabUtility.RecordPrefabInstancePropertyModifications(origin);
driver.RebuildCache();
SetFeedback(
$"{estimate.Message} Parent: {parent.name}.",
estimate.UsedEyeBlendShapes ? MessageType.Info : MessageType.Warning);
SceneView.RepaintAll();
}
private Transform ResolveOriginParent(SkinnedMeshRenderer renderer)
{
if (driver.HeadReference != null)
return driver.HeadReference;
var animator = renderer != null
? renderer.GetComponentInParent(true)
: null;
if (animator == null)
animator = driver.GetComponentInParent(true);
if (animator != null && animator.avatar != null && animator.isHuman)
{
var humanoidHead = animator.GetBoneTransform(HumanBodyBones.Head);
if (humanoidHead != null)
return humanoidHead;
}
Transform suffixMatch = null;
Transform containsMatch = null;
var bones = renderer != null ? renderer.bones : null;
if (bones != null)
{
for (var index = 0; index < bones.Length; index++)
{
var bone = bones[index];
if (bone == null)
continue;
var normalizedName = Normalize(bone.name);
if (normalizedName == "head")
return bone;
if (suffixMatch == null && normalizedName.EndsWith("head", StringComparison.Ordinal))
suffixMatch = bone;
else if (containsMatch == null && normalizedName.Contains("head"))
containsMatch = bone;
}
}
return suffixMatch != null ? suffixMatch : containsMatch;
}
private void AlignOriginForwardToSceneCamera()
{
var origin = driver.GazeOrigin;
var sceneCamera = SceneView.lastActiveSceneView != null
? SceneView.lastActiveSceneView.camera
: null;
if (origin == null || sceneCamera == null)
{
SetFeedback("Open a Scene view and assign Gaze Origin first.", MessageType.Error);
return;
}
var direction = sceneCamera.transform.position - origin.position;
if (direction.sqrMagnitude < 0.000001f)
{
SetFeedback("The Scene camera is too close to Gaze Origin.", MessageType.Error);
return;
}
var up = origin.parent != null
? origin.parent.up
: driver.HeadReference != null ? driver.HeadReference.up : Vector3.up;
if (Mathf.Abs(Vector3.Dot(direction.normalized, up.normalized)) > 0.999f)
up = Vector3.up;
Undo.RecordObject(origin, "Align Gaze Origin Forward");
origin.rotation = Quaternion.LookRotation(direction.normalized, up);
EditorUtility.SetDirty(origin);
PrefabUtility.RecordPrefabInstancePropertyModifications(origin);
driver.RebuildCache();
SetFeedback(
"Gaze Origin forward now points toward the Scene camera and remains authoritative at runtime.",
MessageType.Info);
SceneView.RepaintAll();
}
private void AssignRenderer(SkinnedMeshRenderer renderer)
{
Undo.RecordObject(driver, "Assign Gaze Target Renderer");
driver.TargetRenderer = renderer;
MarkDriverDirty();
}
private void AssignProfile(BlendshapeGazeProfile profile)
{
Undo.RecordObject(driver, "Assign Gaze Calibration Profile");
driver.Profile = profile;
MarkDriverDirty();
}
private void AssignOrigin(Transform origin)
{
Undo.RecordObject(driver, "Assign Gaze Origin");
driver.GazeOrigin = origin;
MarkDriverDirty();
}
private void MarkDriverDirty()
{
EditorUtility.SetDirty(driver);
PrefabUtility.RecordPrefabInstancePropertyModifications(driver);
driver.RebuildCache();
}
private void MarkProfileDirty()
{
if (Session.Profile != null)
EditorUtility.SetDirty(Session.Profile);
Repaint();
}
private void SetDriver(BlendshapeGazeDriver value)
{
if (driver == value)
return;
FinishCalibration(false, false);
driver = value;
feedback = null;
Repaint();
}
private void OnUndoRedo()
{
if (IsCalibrating)
{
ReloadDraftAndPreview();
}
Repaint();
}
private void SetFeedback(string message, MessageType type)
{
feedback = message;
feedbackType = type;
Repaint();
}
private bool CanStartCalibration(out string reason)
{
reason = null;
if (driver == null)
{
reason = "Assign a Blendshape Gaze Driver first.";
return false;
}
var renderer = driver.TargetRenderer;
if (renderer == null || renderer.sharedMesh == null)
{
reason = "Assign a Target Renderer with a Face mesh.";
return false;
}
var profile = driver.Profile;
if (profile == null)
{
reason = "Create or assign a Calibration Profile.";
return false;
}
if (!profile.IsCompatibleWith(renderer.sharedMesh))
{
reason = "The profile Source Mesh does not match the assigned Face mesh.";
return false;
}
if (driver.GazeOrigin == null)
{
reason = "Create or assign Gaze Origin before calibration.";
return false;
}
if (profile.Channels.Count == 0)
{
reason = "Sync ARKit EyeLook 8 or configure profile Channels manually.";
return false;
}
for (var index = 0; index < profile.Channels.Count; index++)
{
var channel = profile.Channels[index];
if (channel == null
|| string.IsNullOrWhiteSpace(channel.BlendShapeName)
|| profile.FindBlendShapeIndex(renderer.sharedMesh, index) < 0)
{
reason = channel == null
? $"Profile channel {index + 1} is invalid."
: $"Blendshape '{channel.BlendShapeName}' is missing on the assigned Face mesh.";
return false;
}
}
return true;
}
private static int CountCaptured(
BlendshapeGazeProfile profile,
IReadOnlyList points)
{
if (profile == null || points == null)
return 0;
var count = 0;
for (var index = 0; index < points.Count; index++)
{
if (profile.IsSampleCaptured(points[index]))
count++;
}
return count;
}
private static bool ContainsPoint(
IReadOnlyList points,
GazeCalibrationPoint point)
{
for (var index = 0; index < points.Count; index++)
{
if (points[index] == point)
return true;
}
return false;
}
private static bool HasAnyCapturedSamples(BlendshapeGazeProfile profile)
{
if (profile == null)
return false;
for (var index = 0; index < 9; index++)
{
if (profile.IsSampleCaptured((GazeCalibrationPoint)index))
return true;
}
return false;
}
private static IReadOnlyList GetConfiguredChannelNames(BlendshapeGazeProfile profile)
{
var names = new List();
if (profile == null)
return names;
for (var index = 0; index < profile.Channels.Count; index++)
{
var channel = profile.Channels[index];
if (channel != null && !string.IsNullOrWhiteSpace(channel.BlendShapeName))
names.Add(channel.BlendShapeName);
}
return names;
}
private static string GetChannelLayout(BlendshapeGazeProfile profile)
{
if (profile == null)
return string.Empty;
var names = new string[profile.Channels.Count];
for (var index = 0; index < names.Length; index++)
{
var channel = profile.Channels[index];
names[index] = channel != null ? channel.BlendShapeName : "";
}
return string.Join("|", names);
}
private static GazeCalibrationPoint GetHorizontalOpposite(GazeCalibrationPoint point)
{
var index = (int)point;
var row = index / 3;
var column = index % 3;
return (GazeCalibrationPoint)(row * 3 + (2 - column));
}
private static Vector2 CoordinatesFor(GazeCalibrationPoint point)
{
var index = (int)point;
return new Vector2(index % 3 - 1, index / 3 - 1);
}
private static Vector2 PointToPad(Rect rect, Vector2 normalized)
{
const float inset = MarkerSize * 0.7f;
return new Vector2(
Mathf.Lerp(rect.x + inset, rect.xMax - inset, (normalized.x + 1f) * 0.5f),
Mathf.Lerp(rect.yMax - inset, rect.y + inset, (normalized.y + 1f) * 0.5f));
}
private static GazeCalibrationPoint FindClosestPoint(
Rect rect,
Vector2 mousePosition,
IReadOnlyList points,
out float distance)
{
var closest = points[0];
distance = float.PositiveInfinity;
for (var index = 0; index < points.Count; index++)
{
var candidateDistance = Vector2.Distance(
mousePosition,
PointToPad(rect, CoordinatesFor(points[index])));
if (candidateDistance < distance)
{
closest = points[index];
distance = candidateDistance;
}
}
return closest;
}
private static string GetPointGlyph(GazeCalibrationPoint point)
{
switch (point)
{
case GazeCalibrationPoint.DownLeft: return "\u2199";
case GazeCalibrationPoint.DownCenter: return "\u2193";
case GazeCalibrationPoint.DownRight: return "\u2198";
case GazeCalibrationPoint.CenterLeft: return "\u2190";
case GazeCalibrationPoint.Center: return "\u25cf";
case GazeCalibrationPoint.CenterRight: return "\u2192";
case GazeCalibrationPoint.UpLeft: return "\u2196";
case GazeCalibrationPoint.UpCenter: return "\u2191";
case GazeCalibrationPoint.UpRight: return "\u2197";
default: return "?";
}
}
private static string GetPointLabel(GazeCalibrationPoint point)
{
switch (point)
{
case GazeCalibrationPoint.DownLeft: return "Down Left";
case GazeCalibrationPoint.DownCenter: return "Down";
case GazeCalibrationPoint.DownRight: return "Down Right";
case GazeCalibrationPoint.CenterLeft: return "Left";
case GazeCalibrationPoint.Center: return "Center";
case GazeCalibrationPoint.CenterRight: return "Right";
case GazeCalibrationPoint.UpLeft: return "Up Left";
case GazeCalibrationPoint.UpCenter: return "Up";
case GazeCalibrationPoint.UpRight: return "Up Right";
default: return point.ToString();
}
}
private static ChannelSide GetChannelSide(string channelName)
{
if (string.IsNullOrWhiteSpace(channelName))
return ChannelSide.Other;
var normalized = Normalize(channelName);
if (normalized.EndsWith("left", StringComparison.Ordinal))
return ChannelSide.Left;
if (normalized.EndsWith("right", StringComparison.Ordinal))
return ChannelSide.Right;
return ChannelSide.Other;
}
private static string Normalize(string value)
{
var characters = new char[value.Length];
var count = 0;
for (var index = 0; index < value.Length; index++)
{
var character = value[index];
if (char.IsLetterOrDigit(character))
characters[count++] = char.ToLowerInvariant(character);
}
return new string(characters, 0, count);
}
private enum ChannelSide
{
Left,
Right,
Other
}
}
}