1124 lines
42 KiB
C#
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; }
|
|
}
|
|
}
|
|
}
|