streamingle-unity-utilities/Editor/Gaze/BlendshapeGazeDriverEditor.cs

1124 lines
42 KiB
C#

using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEditor;
using UnityEngine;
namespace Streamingle.Gaze.Editor
{
[CustomEditor(typeof(BlendshapeGazeDriver))]
public sealed class BlendshapeGazeDriverEditor : UnityEditor.Editor
{
private static readonly string[] StandardEyeLookChannels =
{
"eyeLookUpLeft",
"eyeLookUpRight",
"eyeLookDownLeft",
"eyeLookDownRight",
"eyeLookInLeft",
"eyeLookInRight",
"eyeLookOutLeft",
"eyeLookOutRight"
};
private static readonly GazeCalibrationPoint[] GridSamples =
{
GazeCalibrationPoint.UpLeft,
GazeCalibrationPoint.UpCenter,
GazeCalibrationPoint.UpRight,
GazeCalibrationPoint.CenterLeft,
GazeCalibrationPoint.Center,
GazeCalibrationPoint.CenterRight,
GazeCalibrationPoint.DownLeft,
GazeCalibrationPoint.DownCenter,
GazeCalibrationPoint.DownRight
};
private static readonly string[] GridLabels =
{
"Up Left", "Up", "Up Right",
"Left", "Center", "Right",
"Down Left", "Down", "Down Right"
};
private static readonly string[] RendererPropertyNames =
{
"targetRenderer", "_targetRenderer", "m_TargetRenderer", "renderer", "faceRenderer"
};
private static readonly string[] ProfilePropertyNames =
{
"profile", "_profile", "m_Profile", "calibrationProfile"
};
private static readonly string[] OriginPropertyNames =
{
"gazeOrigin", "_gazeOrigin", "m_GazeOrigin", "origin", "gazeBasis"
};
private static readonly string[] HeadPropertyNames =
{
"headReference", "_headReference", "m_HeadReference", "head", "headTransform"
};
private readonly Dictionary<int, float> previewRestoreWeights = new Dictionary<int, float>();
private readonly HashSet<int> capturedThisSession = new HashSet<int>();
private SkinnedMeshRenderer previewRenderer;
private SerializedProperty targetRendererProperty;
private SerializedProperty profileProperty;
private SerializedProperty gazeOriginProperty;
private SerializedProperty headReferenceProperty;
private int selectedSampleIndex = 4;
private bool editOriginInScene;
private string feedbackMessage;
private MessageType feedbackType = MessageType.Info;
private GazeOriginEstimate lastOriginEstimate;
private bool hasOriginEstimate;
private BlendshapeGazeDriver Driver => (BlendshapeGazeDriver)target;
private string SessionKey => $"Streamingle.Gaze.SelectedSample.{target.GetInstanceID()}";
private void OnEnable()
{
CacheDriverProperties();
selectedSampleIndex = Mathf.Clamp(SessionState.GetInt(SessionKey, 4), 0, GridSamples.Length - 1);
}
private void OnDisable()
{
// Applying a sample is a non-destructive preview. Do not leave it stuck on the face when
// the inspector is closed or the user selects another object.
RestorePreview(false);
}
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUI.BeginChangeCheck();
DrawPropertiesExcluding(serializedObject, "m_Script");
bool defaultInspectorChanged = EditorGUI.EndChangeCheck();
serializedObject.ApplyModifiedProperties();
if (defaultInspectorChanged)
{
RestorePreview(false);
CacheDriverProperties();
RefreshDriverCache();
}
EditorGUILayout.Space(8f);
DrawCalibrationTools();
}
private void DrawCalibrationTools()
{
EditorGUILayout.LabelField("Gaze Calibration", EditorStyles.boldLabel);
SkinnedMeshRenderer renderer = Driver.TargetRenderer;
BlendshapeGazeProfile profile = Driver.Profile;
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
if (GUILayout.Button("Open Guided Calibration", GUILayout.MinHeight(36f)))
{
GazeCalibrationWindow.Open(Driver);
}
EditorGUILayout.LabelField(
"Recommended: edit five poses directly, generate corners, and test interpolation in one window.",
EditorStyles.wordWrappedMiniLabel);
}
DrawValidation(renderer, profile);
if (!string.IsNullOrWhiteSpace(feedbackMessage))
{
EditorGUILayout.HelpBox(feedbackMessage, feedbackType);
}
}
private void DrawProfileTools(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile)
{
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUI.DisabledScope(profile != null))
{
if (GUILayout.Button("Create Profile Asset"))
{
CreateProfileAsset();
GUIUtility.ExitGUI();
}
}
using (new EditorGUI.DisabledScope(profile == null || renderer == null || renderer.sharedMesh == null))
{
if (GUILayout.Button("Sync ARKit EyeLook 8"))
{
SyncStandardChannels(renderer, profile);
}
}
}
if (renderer == null)
{
EditorGUILayout.HelpBox("Assign Target Renderer before calibrating.", MessageType.Warning);
}
else if (renderer.sharedMesh == null)
{
EditorGUILayout.HelpBox("Target Renderer has no shared mesh.", MessageType.Warning);
}
}
private void DrawOriginTools(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile)
{
EditorGUILayout.LabelField("Gaze Origin", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUI.DisabledScope(renderer == null))
{
string originLabel = Driver.GazeOrigin == null
? "Auto Create Gaze Origin"
: "Recalculate Gaze Origin";
if (GUILayout.Button(originLabel))
{
AutoCreateOrRecalculateOrigin(renderer, profile);
}
}
using (new EditorGUI.DisabledScope(Driver.GazeOrigin == null && renderer == null))
{
if (GUILayout.Button("Set Forward From Scene Camera"))
{
SetNeutralForwardFromSceneCamera(renderer, profile);
}
}
}
using (new EditorGUI.DisabledScope(Driver.GazeOrigin == null))
{
bool newEditState = EditorGUILayout.ToggleLeft("Edit origin position in Scene view", editOriginInScene);
if (newEditState != editOriginInScene)
{
editOriginInScene = newEditState;
SceneView.RepaintAll();
}
}
}
private void DrawSampleGrid(BlendshapeGazeProfile profile)
{
for (int row = 0; row < 3; row++)
{
using (new EditorGUILayout.HorizontalScope())
{
for (int column = 0; column < 3; column++)
{
int index = row * 3 + column;
bool isSelected = selectedSampleIndex == index;
bool isCaptured = IsSampleCaptured(profile, index);
Color previousColor = GUI.backgroundColor;
if (isSelected)
{
GUI.backgroundColor = new Color(0.35f, 0.72f, 1f);
}
else if (isCaptured)
{
GUI.backgroundColor = new Color(0.45f, 0.8f, 0.48f);
}
string state = isCaptured ? " [Captured]" : string.Empty;
if (GUILayout.Button(GridLabels[index] + state, GUILayout.MinHeight(30f)))
{
selectedSampleIndex = index;
SessionState.SetInt(SessionKey, selectedSampleIndex);
SceneView.RepaintAll();
}
GUI.backgroundColor = previousColor;
}
}
}
EditorGUILayout.LabelField(
$"Selected: {GridLabels[selectedSampleIndex]}",
EditorStyles.miniLabel);
}
private void DrawSampleActions(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile)
{
bool canUseSamples = renderer != null && renderer.sharedMesh != null && profile != null;
using (new EditorGUI.DisabledScope(!canUseSamples))
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Capture Current Weights"))
{
CaptureSelectedSample(renderer, profile);
}
if (GUILayout.Button("Apply Selected Sample"))
{
ApplySelectedSample(renderer, profile);
}
using (new EditorGUI.DisabledScope(previewRestoreWeights.Count == 0))
{
if (GUILayout.Button("Restore"))
{
RestorePreview(true);
}
}
}
}
private void DrawValidation(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile)
{
if (renderer == null || profile == null)
{
return;
}
Mesh mesh = renderer.sharedMesh;
if (mesh == null)
{
EditorGUILayout.HelpBox("Target Renderer has no shared mesh.", MessageType.Warning);
return;
}
if (profile.Channels.Count == 0)
{
EditorGUILayout.HelpBox("Sync the eight eyeLook channels before capturing samples.", MessageType.Warning);
return;
}
var missingChannels = new List<string>();
if (!profile.IsCompatibleWith(mesh))
{
EditorGUILayout.HelpBox(
$"This profile was calibrated for '{profile.SourceMesh.name}', not '{mesh.name}'.",
MessageType.Warning);
return;
}
for (int index = 0; index < profile.Channels.Count; index++)
{
GazeBlendShapeChannel channel = profile.Channels[index];
if (channel == null || BlendshapeGazeProfile.FindBlendShapeIndex(mesh, channel.BlendShapeName) < 0)
{
missingChannels.Add(channel != null ? channel.BlendShapeName : "<null>");
}
}
if (missingChannels.Count > 0)
{
EditorGUILayout.HelpBox(
"Missing blendshapes on Target Renderer: " + string.Join(", ", missingChannels),
MessageType.Warning);
return;
}
int capturedCount = 0;
for (int index = 0; index < profile.Samples.Count; index++)
{
if (profile.Samples[index] != null && profile.Samples[index].Captured)
{
capturedCount++;
}
}
EditorGUILayout.HelpBox(
profile.IsFullyCalibrated
? "All 9 gaze samples are calibrated."
: $"Calibration progress: {capturedCount} / 9 samples.",
profile.IsFullyCalibrated ? MessageType.Info : MessageType.None);
}
private void CreateProfileAsset()
{
string driverName = string.IsNullOrWhiteSpace(Driver.gameObject.name)
? "Character"
: Driver.gameObject.name;
string path = EditorUtility.SaveFilePanelInProject(
"Create Gaze Calibration Profile",
driverName + "_GazeCalibration",
"asset",
"Choose where to save the character-specific gaze calibration profile.");
if (string.IsNullOrWhiteSpace(path))
{
return;
}
var profile = ScriptableObject.CreateInstance<BlendshapeGazeProfile>();
profile.name = System.IO.Path.GetFileNameWithoutExtension(path);
AssetDatabase.CreateAsset(profile, path);
AssetDatabase.SaveAssets();
Undo.RecordObject(Driver, "Assign Gaze Calibration Profile");
if (!SetDriverObjectReference(profileProperty, ProfilePropertyNames, profile))
{
AssetDatabase.DeleteAsset(path);
SetFeedback("Could not find the driver's serialized Profile field.", MessageType.Error);
return;
}
EditorUtility.SetDirty(Driver);
EditorUtility.SetDirty(profile);
PrefabUtility.RecordPrefabInstancePropertyModifications(Driver);
RefreshDriverCache();
SetFeedback("Created and assigned a gaze calibration profile.", MessageType.Info);
EditorGUIUtility.PingObject(profile);
}
private void SyncStandardChannels(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile)
{
if (!TryFindStandardChannels(renderer.sharedMesh, out List<BlendShapeMatch> matches, out string matchIssue))
{
SetFeedback(matchIssue, MessageType.Error);
return;
}
var preservedSamples = new Dictionary<GazeCalibrationPoint, Dictionary<string, float>>();
IReadOnlyList<GazeBlendShapeChannel> existingChannels = profile.Channels;
IReadOnlyList<GazeCalibrationSample> existingSamples = profile.Samples;
for (int pointIndex = 0; pointIndex < 9 && pointIndex < existingSamples.Count; pointIndex++)
{
GazeCalibrationSample sample = existingSamples[pointIndex];
if (sample == null || !sample.Captured)
{
continue;
}
var weightsByChannel = new Dictionary<string, float>(StringComparer.Ordinal);
for (int channelIndex = 0; channelIndex < existingChannels.Count; channelIndex++)
{
GazeBlendShapeChannel channel = existingChannels[channelIndex];
if (channel == null || channelIndex >= sample.Weights.Count)
{
continue;
}
weightsByChannel[GetStableChannelKey(channel.BlendShapeName)] = sample.Weights[channelIndex];
}
preservedSamples[(GazeCalibrationPoint)pointIndex] = weightsByChannel;
}
var replacements = new List<GazeBlendShapeChannel>(matches.Count + existingChannels.Count);
for (int index = 0; index < matches.Count; index++)
{
replacements.Add(new GazeBlendShapeChannel(matches[index].MeshName, GazeChannelUsage.EyeLook));
}
// Channel sync owns the primary eight, but must not destroy optional eyelid or other
// corrective channels that were explicitly authored later.
for (int index = 0; index < existingChannels.Count; index++)
{
GazeBlendShapeChannel channel = existingChannels[index];
if (channel == null || channel.Usage != GazeChannelUsage.Corrective)
{
continue;
}
bool duplicate = false;
for (int replacementIndex = 0; replacementIndex < replacements.Count; replacementIndex++)
{
if (string.Equals(
replacements[replacementIndex].BlendShapeName,
channel.BlendShapeName,
StringComparison.OrdinalIgnoreCase))
{
duplicate = true;
break;
}
}
if (!duplicate)
{
replacements.Add(new GazeBlendShapeChannel(
channel.BlendShapeName,
GazeChannelUsage.Corrective,
channel.CorrectiveBlendMode));
}
}
Undo.RecordObject(profile, "Sync ARKit EyeLook Channels");
profile.ReplaceChannels(replacements);
profile.SetSourceMesh(renderer.sharedMesh);
foreach (KeyValuePair<GazeCalibrationPoint, Dictionary<string, float>> pair in preservedSamples)
{
var restoredWeights = new float[replacements.Count];
for (int channelIndex = 0; channelIndex < replacements.Count; channelIndex++)
{
string key = GetStableChannelKey(replacements[channelIndex].BlendShapeName);
if (pair.Value.TryGetValue(key, out float value))
{
restoredWeights[channelIndex] = value;
}
}
profile.SetSample(pair.Key, restoredWeights);
}
EditorUtility.SetDirty(profile);
AssetDatabase.SaveAssetIfDirty(profile);
RefreshDriverCache();
SetFeedback(
$"Synced all {StandardEyeLookChannels.Length} ARKit eyeLook channels from {renderer.sharedMesh.name}.",
MessageType.Info);
}
private void CaptureSelectedSample(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile)
{
Undo.RecordObject(profile, "Capture Gaze Calibration Sample");
if (!profile.CaptureSample(GridSamples[selectedSampleIndex], renderer))
{
SetFeedback(
"Capture failed. Sync channels and make sure every configured blendshape exists on Target Renderer.",
MessageType.Error);
return;
}
capturedThisSession.Add(selectedSampleIndex);
EditorUtility.SetDirty(profile);
AssetDatabase.SaveAssetIfDirty(profile);
RefreshDriverCache();
SetFeedback($"Captured {GridLabels[selectedSampleIndex]} from the current blendshape weights.", MessageType.Info);
Repaint();
SceneView.RepaintAll();
}
private void ApplySelectedSample(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile)
{
GazeCalibrationPoint point = GridSamples[selectedSampleIndex];
int sampleIndex = (int)point;
if (sampleIndex < 0 || sampleIndex >= profile.Samples.Count
|| profile.Samples[sampleIndex] == null
|| !profile.Samples[sampleIndex].Captured)
{
SetFeedback("The selected sample has not been captured yet.", MessageType.Warning);
return;
}
CachePreviewWeights(renderer);
Undo.RecordObject(renderer, "Preview Gaze Calibration Sample");
GazeCalibrationSample sample = profile.Samples[sampleIndex];
Mesh mesh = renderer.sharedMesh;
for (int channelIndex = 0; channelIndex < profile.Channels.Count; channelIndex++)
{
int blendShapeIndex = profile.FindBlendShapeIndex(mesh, channelIndex);
if (blendShapeIndex < 0 || channelIndex >= sample.Weights.Count)
{
continue;
}
renderer.SetBlendShapeWeight(blendShapeIndex, sample.Weights[channelIndex]);
}
EditorUtility.SetDirty(renderer);
PrefabUtility.RecordPrefabInstancePropertyModifications(renderer);
SetFeedback($"Previewing {GridLabels[selectedSampleIndex]}. Restore returns to the previous face pose.", MessageType.Info);
SceneView.RepaintAll();
}
private void CachePreviewWeights(SkinnedMeshRenderer renderer)
{
if (previewRestoreWeights.Count > 0 && previewRenderer == renderer)
{
return;
}
previewRestoreWeights.Clear();
previewRenderer = renderer;
Mesh mesh = renderer != null ? renderer.sharedMesh : null;
if (mesh == null)
{
return;
}
for (int index = 0; index < mesh.blendShapeCount; index++)
{
previewRestoreWeights[index] = renderer.GetBlendShapeWeight(index);
}
}
private void RestorePreview(bool recordUndo)
{
if (previewRenderer == null || previewRestoreWeights.Count == 0)
{
previewRestoreWeights.Clear();
previewRenderer = null;
return;
}
if (recordUndo)
{
Undo.RecordObject(previewRenderer, "Restore Face Before Gaze Preview");
}
Mesh mesh = previewRenderer.sharedMesh;
if (mesh != null)
{
foreach (KeyValuePair<int, float> pair in previewRestoreWeights)
{
if (pair.Key >= 0 && pair.Key < mesh.blendShapeCount)
{
previewRenderer.SetBlendShapeWeight(pair.Key, pair.Value);
}
}
EditorUtility.SetDirty(previewRenderer);
PrefabUtility.RecordPrefabInstancePropertyModifications(previewRenderer);
}
previewRestoreWeights.Clear();
previewRenderer = null;
SceneView.RepaintAll();
}
private void AutoCreateOrRecalculateOrigin(
SkinnedMeshRenderer renderer,
BlendshapeGazeProfile profile)
{
IReadOnlyList<string> channelNames = GetConfiguredChannelNames(profile);
GazeOriginEstimate estimate = GazeOriginEstimator.Estimate(renderer, channelNames);
lastOriginEstimate = estimate;
hasOriginEstimate = true;
Transform origin = Driver.GazeOrigin;
if (origin == null)
{
Transform parent = ResolveOriginParent();
var marker = new GameObject(Driver.gameObject.name + "_GazeOrigin");
Undo.RegisterCreatedObjectUndo(marker, "Create Gaze Origin");
origin = marker.transform;
Undo.SetTransformParent(origin, parent, "Parent Gaze Origin");
origin.rotation = parent != null ? parent.rotation : Driver.transform.rotation;
Undo.RecordObject(Driver, "Assign Gaze Origin");
if (!SetDriverObjectReference(gazeOriginProperty, OriginPropertyNames, origin))
{
Undo.DestroyObjectImmediate(marker);
SetFeedback("Could not find the driver's serialized Gaze Origin field.", MessageType.Error);
return;
}
}
Undo.RecordObject(origin, "Recalculate Gaze Origin");
origin.position = estimate.WorldPosition;
EditorUtility.SetDirty(origin);
EditorUtility.SetDirty(Driver);
PrefabUtility.RecordPrefabInstancePropertyModifications(origin);
PrefabUtility.RecordPrefabInstancePropertyModifications(Driver);
RefreshDriverCache();
SetFeedback(
estimate.Message,
estimate.UsedEyeBlendShapes ? MessageType.Info : MessageType.Warning);
EditorGUIUtility.PingObject(origin.gameObject);
SceneView.RepaintAll();
}
private void SetNeutralForwardFromSceneCamera(
SkinnedMeshRenderer renderer,
BlendshapeGazeProfile profile)
{
if (Driver.GazeOrigin == null)
{
if (renderer == null)
{
SetFeedback("Assign Target Renderer before creating Gaze Origin.", MessageType.Error);
return;
}
AutoCreateOrRecalculateOrigin(renderer, profile);
}
Transform origin = Driver.GazeOrigin;
Camera sceneCamera = SceneView.lastActiveSceneView != null
? SceneView.lastActiveSceneView.camera
: null;
if (origin == null || sceneCamera == null)
{
SetFeedback("No active Scene view camera was found.", MessageType.Error);
return;
}
Vector3 direction = sceneCamera.transform.position - origin.position;
if (direction.sqrMagnitude < 1e-8f)
{
direction = -sceneCamera.transform.forward;
}
Vector3 up = origin.parent != null ? origin.parent.up : Vector3.up;
if (Mathf.Abs(Vector3.Dot(direction.normalized, up.normalized)) > 0.999f)
{
up = sceneCamera.transform.up;
}
Undo.RecordObject(origin, "Set Neutral Gaze Forward");
origin.rotation = Quaternion.LookRotation(direction.normalized, up);
EditorUtility.SetDirty(origin);
PrefabUtility.RecordPrefabInstancePropertyModifications(origin);
RefreshDriverCache();
SetFeedback("Neutral forward now points from Gaze Origin to the Scene view camera.", MessageType.Info);
SceneView.RepaintAll();
}
private Transform ResolveOriginParent()
{
Transform explicitHead = GetObjectReference<Transform>(headReferenceProperty, Driver, HeadPropertyNames);
if (explicitHead != null)
{
return explicitHead;
}
Animator animator = Driver.GetComponentInParent<Animator>();
if (animator != null && animator.isHuman)
{
Transform humanoidHead = animator.GetBoneTransform(HumanBodyBones.Head);
if (humanoidHead != null)
{
return humanoidHead;
}
}
return Driver.transform;
}
private void OnSceneGUI()
{
Transform origin = Driver.GazeOrigin;
if (origin == null)
{
return;
}
float handleSize = HandleUtility.GetHandleSize(origin.position);
Handles.color = new Color(0.1f, 0.9f, 1f, 0.95f);
Handles.ArrowHandleCap(
0,
origin.position,
origin.rotation,
handleSize * 0.65f,
EventType.Repaint);
Handles.Label(
origin.position + origin.up * handleSize * 0.16f,
$"Gaze Origin\n{GridLabels[selectedSampleIndex]}");
if (hasOriginEstimate && lastOriginEstimate.UsedEyeBlendShapes)
{
Handles.color = new Color(0.3f, 1f, 0.4f, 0.8f);
float eyeHandleSize = handleSize * 0.04f;
Handles.SphereHandleCap(0, lastOriginEstimate.LeftEyeWorldPosition, Quaternion.identity, eyeHandleSize, EventType.Repaint);
Handles.SphereHandleCap(0, lastOriginEstimate.RightEyeWorldPosition, Quaternion.identity, eyeHandleSize, EventType.Repaint);
Handles.DrawDottedLine(
lastOriginEstimate.LeftEyeWorldPosition,
lastOriginEstimate.RightEyeWorldPosition,
3f);
}
bool hasDebugTarget = Driver.HasActiveRequest;
Vector3 debugTargetPosition = Driver.LastTargetPosition;
if (!hasDebugTarget)
{
Camera mainCamera = Camera.main;
if (mainCamera != null)
{
hasDebugTarget = true;
debugTargetPosition = mainCamera.transform.position;
}
}
if (hasDebugTarget)
{
Vector3 targetDirection = debugTargetPosition - origin.position;
if (targetDirection.sqrMagnitude > 1e-8f)
{
Handles.color = new Color(1f, 0.78f, 0.1f, 0.8f);
Handles.DrawDottedLine(origin.position, debugTargetPosition, 4f);
Vector3 localDirection = origin.InverseTransformDirection(targetDirection.normalized);
float yaw = Mathf.Atan2(localDirection.x, localDirection.z) * Mathf.Rad2Deg;
float pitch = Mathf.Atan2(
localDirection.y,
Mathf.Sqrt(localDirection.x * localDirection.x + localDirection.z * localDirection.z)) * Mathf.Rad2Deg;
Handles.Label(
origin.position + targetDirection.normalized * handleSize * 0.8f,
$"Camera yaw {yaw:F1} deg pitch {pitch:F1} deg");
}
}
if (!editOriginInScene)
{
return;
}
EditorGUI.BeginChangeCheck();
Vector3 newPosition = Handles.PositionHandle(origin.position, origin.rotation);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(origin, "Move Gaze Origin");
origin.position = newPosition;
EditorUtility.SetDirty(origin);
PrefabUtility.RecordPrefabInstancePropertyModifications(origin);
RefreshDriverCache();
}
}
private void CacheDriverProperties()
{
serializedObject.UpdateIfRequiredOrScript();
targetRendererProperty = FindProperty(serializedObject, RendererPropertyNames);
profileProperty = FindProperty(serializedObject, ProfilePropertyNames);
gazeOriginProperty = FindProperty(serializedObject, OriginPropertyNames);
headReferenceProperty = FindProperty(serializedObject, HeadPropertyNames);
}
private void RefreshDriverCache()
{
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
MethodInfo refreshMethod = Driver.GetType().GetMethod("RebuildCache", flags)
?? Driver.GetType().GetMethod("InvalidateCache", flags);
refreshMethod?.Invoke(Driver, null);
EditorUtility.SetDirty(Driver);
}
private bool SetDriverObjectReference(
SerializedProperty serializedProperty,
IReadOnlyList<string> memberNames,
UnityEngine.Object value)
{
if (serializedProperty != null && serializedProperty.propertyType == SerializedPropertyType.ObjectReference)
{
serializedObject.Update();
serializedProperty.objectReferenceValue = value;
serializedObject.ApplyModifiedProperties();
CacheDriverProperties();
return true;
}
return TrySetMemberValue(Driver, memberNames, value);
}
private static T GetObjectReference<T>(
SerializedProperty serializedProperty,
object owner,
IReadOnlyList<string> memberNames)
where T : UnityEngine.Object
{
if (serializedProperty != null && serializedProperty.propertyType == SerializedPropertyType.ObjectReference)
{
return serializedProperty.objectReferenceValue as T;
}
return GetMemberValue(owner, memberNames) as T;
}
private static bool TrySetMemberValue(
object owner,
IReadOnlyList<string> memberNames,
object value)
{
if (owner == null)
{
return false;
}
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
Type type = owner.GetType();
for (int index = 0; index < memberNames.Count; index++)
{
string memberName = memberNames[index];
FieldInfo field = type.GetField(memberName, flags);
if (field != null && (value == null || field.FieldType.IsInstanceOfType(value)))
{
field.SetValue(owner, value);
return true;
}
PropertyInfo property = type.GetProperty(memberName, flags);
if (property != null && property.CanWrite && (value == null || property.PropertyType.IsInstanceOfType(value)))
{
property.SetValue(owner, value);
return true;
}
}
return false;
}
private static object GetMemberValue(object owner, IReadOnlyList<string> memberNames)
{
if (owner == null)
{
return null;
}
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
Type type = owner.GetType();
for (int index = 0; index < memberNames.Count; index++)
{
string memberName = memberNames[index];
FieldInfo field = type.GetField(memberName, flags);
if (field != null)
{
return field.GetValue(owner);
}
PropertyInfo property = type.GetProperty(memberName, flags);
if (property != null && property.CanRead)
{
return property.GetValue(owner);
}
}
return null;
}
private static SerializedProperty FindProperty(SerializedObject owner, IReadOnlyList<string> names)
{
if (owner == null)
{
return null;
}
for (int index = 0; index < names.Count; index++)
{
SerializedProperty exact = owner.FindProperty(names[index]);
if (exact != null)
{
return exact;
}
}
var normalizedNames = new HashSet<string>(StringComparer.Ordinal);
for (int index = 0; index < names.Count; index++)
{
normalizedNames.Add(Normalize(names[index]));
}
SerializedProperty iterator = owner.GetIterator();
bool enterChildren = true;
while (iterator.NextVisible(enterChildren))
{
enterChildren = false;
if (iterator.depth == 0 && normalizedNames.Contains(Normalize(iterator.name)))
{
return iterator.Copy();
}
}
return null;
}
private static bool TryFindStandardChannels(
Mesh mesh,
out List<BlendShapeMatch> matches,
out string issue)
{
matches = new List<BlendShapeMatch>(StandardEyeLookChannels.Length);
var missing = new List<string>();
for (int standardIndex = 0; standardIndex < StandardEyeLookChannels.Length; standardIndex++)
{
string standardName = StandardEyeLookChannels[standardIndex];
int meshIndex = FindBestBlendShapeMatch(mesh, standardName);
if (meshIndex < 0)
{
missing.Add(standardName);
continue;
}
matches.Add(new BlendShapeMatch(
standardName,
mesh.GetBlendShapeName(meshIndex),
meshIndex));
}
if (missing.Count > 0)
{
issue = "The mesh is missing ARKit eyeLook channels: " + string.Join(", ", missing);
return false;
}
issue = null;
return true;
}
private static int FindBestBlendShapeMatch(Mesh mesh, string standardName)
{
if (mesh == null)
{
return -1;
}
string normalizedStandardName = Normalize(standardName);
int bestIndex = -1;
int bestScore = -1;
int bestNameLength = int.MaxValue;
for (int index = 0; index < mesh.blendShapeCount; index++)
{
string candidate = mesh.GetBlendShapeName(index);
string normalizedCandidate = Normalize(candidate);
int score = -1;
if (string.Equals(candidate, standardName, StringComparison.OrdinalIgnoreCase))
{
score = 100;
}
else if (candidate.EndsWith(standardName, StringComparison.OrdinalIgnoreCase))
{
score = 80;
}
else if (normalizedCandidate == normalizedStandardName)
{
score = 60;
}
else if (normalizedCandidate.EndsWith(normalizedStandardName, StringComparison.Ordinal))
{
score = 40;
}
if (score > bestScore || score == bestScore && candidate.Length < bestNameLength)
{
bestScore = score;
bestIndex = index;
bestNameLength = candidate.Length;
}
}
return bestIndex;
}
private static bool IsNameOrSuffixMatch(string candidate, string standardName)
{
if (string.IsNullOrWhiteSpace(candidate) || string.IsNullOrWhiteSpace(standardName))
{
return false;
}
if (string.Equals(candidate, standardName, StringComparison.OrdinalIgnoreCase)
|| candidate.EndsWith(standardName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
string normalizedCandidate = Normalize(candidate);
string normalizedStandard = Normalize(standardName);
return normalizedCandidate == normalizedStandard
|| normalizedCandidate.EndsWith(normalizedStandard, StringComparison.Ordinal);
}
private static string GetStableChannelKey(string channelName)
{
for (int index = 0; index < StandardEyeLookChannels.Length; index++)
{
if (IsNameOrSuffixMatch(channelName, StandardEyeLookChannels[index]))
{
return "arkit:" + Normalize(StandardEyeLookChannels[index]);
}
}
return "custom:" + Normalize(channelName);
}
private static IReadOnlyList<string> GetConfiguredChannelNames(BlendshapeGazeProfile profile)
{
var names = new List<string>();
if (profile == null)
{
names.AddRange(StandardEyeLookChannels);
return names;
}
for (int index = 0; index < profile.Channels.Count; index++)
{
GazeBlendShapeChannel channel = profile.Channels[index];
if (channel != null
&& channel.Usage == GazeChannelUsage.EyeLook
&& !string.IsNullOrWhiteSpace(channel.BlendShapeName))
{
names.Add(channel.BlendShapeName);
}
}
if (names.Count > 0)
{
return names;
}
names.AddRange(StandardEyeLookChannels);
return names;
}
private bool IsSampleCaptured(BlendshapeGazeProfile profile, int sampleIndex)
{
if (profile == null)
{
return false;
}
if (capturedThisSession.Contains(sampleIndex))
{
return true;
}
int profileSampleIndex = (int)GridSamples[sampleIndex];
if (profileSampleIndex < 0 || profileSampleIndex >= profile.Samples.Count)
{
return false;
}
GazeCalibrationSample sample = profile.Samples[profileSampleIndex];
return sample != null && sample.Captured;
}
private void SetFeedback(string message, MessageType type)
{
feedbackMessage = message;
feedbackType = type;
Repaint();
}
private static string Normalize(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
var characters = new char[value.Length];
int count = 0;
for (int index = 0; index < value.Length; index++)
{
char character = value[index];
if (!char.IsLetterOrDigit(character))
{
continue;
}
characters[count++] = char.ToLowerInvariant(character);
}
return new string(characters, 0, count);
}
private readonly struct BlendShapeMatch
{
internal BlendShapeMatch(string canonicalName, string meshName, int meshIndex)
{
CanonicalName = canonicalName;
MeshName = meshName;
MeshIndex = meshIndex;
}
internal string CanonicalName { get; }
internal string MeshName { get; }
internal int MeshIndex { get; }
}
}
}