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 } } }