using System; using System.Collections.Generic; using System.Text; using UnityEngine; namespace Streamingle.Gaze { [CreateAssetMenu(fileName = "BlendshapeGazeProfile", menuName = "Streamingle Utilities/Gaze/Blendshape Gaze Profile")] public sealed class BlendshapeGazeProfile : ScriptableObject { private const float DefaultMinYaw = -35f; private const float DefaultMaxYaw = 35f; private const float DefaultMinPitch = -25f; private const float DefaultMaxPitch = 20f; private static readonly string[] StandardEyeLookNames = { "eyeLookUpLeft", "eyeLookUpRight", "eyeLookDownLeft", "eyeLookDownRight", "eyeLookInLeft", "eyeLookInRight", "eyeLookOutLeft", "eyeLookOutRight" }; [Header("Direction Limits")] [SerializeField, Range(-90f, -0.1f)] private float minYaw = DefaultMinYaw; [SerializeField, Range(0.1f, 90f)] private float maxYaw = DefaultMaxYaw; [SerializeField, Range(-90f, -0.1f)] private float minPitch = DefaultMinPitch; [SerializeField, Range(0.1f, 90f)] private float maxPitch = DefaultMaxPitch; [Header("Source Mesh")] [SerializeField] [Tooltip("Optional calibration mesh guard. Clear this only when intentionally sharing a profile across compatible meshes.")] private Mesh sourceMesh; [Header("Blendshape Mapping")] [SerializeField] private List channels = new List(); [Header("3x3 Calibration")] [SerializeField] private List samples = new List(9); [SerializeField, HideInInspector] private string channelLayoutSignature; public float MinYaw => minYaw; public float MaxYaw => maxYaw; public float MinPitch => minPitch; public float MaxPitch => maxPitch; public Mesh SourceMesh => sourceMesh; public IReadOnlyList Channels => channels; public IReadOnlyList Samples => samples; public bool IsFullyCalibrated { get { EnsureGrid(); if (channels.Count == 0) return false; for (var i = 0; i < samples.Count; i++) { if (samples[i] == null || !samples[i].Captured || samples[i].Weights.Count != channels.Count) return false; } return true; } } public bool IsSampleCaptured(GazeCalibrationPoint point) { EnsureGrid(); var sampleIndex = (int)point; return sampleIndex >= 0 && sampleIndex < samples.Count && samples[sampleIndex] != null && samples[sampleIndex].Captured && samples[sampleIndex].Weights.Count == channels.Count; } public float[] GetSampleWeightsCopy(GazeCalibrationPoint point) { EnsureGrid(); var sampleIndex = (int)point; if (sampleIndex < 0 || sampleIndex >= samples.Count) return Array.Empty(); var copy = new float[channels.Count]; var sample = samples[sampleIndex]; if (sample == null) return copy; for (var i = 0; i < copy.Length; i++) copy[i] = sample.GetWeight(i); return copy; } public bool CanGenerateCorners { get { return channels.Count > 0 && IsSampleCaptured(GazeCalibrationPoint.Center) && IsSampleCaptured(GazeCalibrationPoint.CenterLeft) && IsSampleCaptured(GazeCalibrationPoint.CenterRight) && IsSampleCaptured(GazeCalibrationPoint.UpCenter) && IsSampleCaptured(GazeCalibrationPoint.DownCenter); } } public bool GenerateCornersFromCardinals() { if (!CanGenerateCorners) return false; var center = GetSampleWeightsCopy(GazeCalibrationPoint.Center); var left = GetSampleWeightsCopy(GazeCalibrationPoint.CenterLeft); var right = GetSampleWeightsCopy(GazeCalibrationPoint.CenterRight); var up = GetSampleWeightsCopy(GazeCalibrationPoint.UpCenter); var down = GetSampleWeightsCopy(GazeCalibrationPoint.DownCenter); SetGeneratedCorner(GazeCalibrationPoint.DownLeft, down, left, center); SetGeneratedCorner(GazeCalibrationPoint.DownRight, down, right, center); SetGeneratedCorner(GazeCalibrationPoint.UpLeft, up, left, center); SetGeneratedCorner(GazeCalibrationPoint.UpRight, up, right, center); return true; } public bool SeedStandardEyeLook(float maximumWeight = 100f) { if (channels.Count == 0 || float.IsNaN(maximumWeight) || float.IsInfinity(maximumWeight)) return false; var standardIndices = new int[StandardEyeLookNames.Length]; for (var i = 0; i < StandardEyeLookNames.Length; i++) { standardIndices[i] = FindChannelIndexByNormalizedSuffix(StandardEyeLookNames[i]); if (standardIndices[i] < 0) return false; } maximumWeight = Mathf.Clamp(maximumWeight, 0f, 100f); var center = new float[channels.Count]; var left = new float[channels.Count]; var right = new float[channels.Count]; var up = new float[channels.Count]; var down = new float[channels.Count]; // StandardEyeLookNames order: Up L/R, Down L/R, In L/R, Out L/R. up[standardIndices[0]] = maximumWeight; up[standardIndices[1]] = maximumWeight; down[standardIndices[2]] = maximumWeight; down[standardIndices[3]] = maximumWeight; right[standardIndices[4]] = maximumWeight; left[standardIndices[5]] = maximumWeight; left[standardIndices[6]] = maximumWeight; right[standardIndices[7]] = maximumWeight; EnsureGrid(); samples[(int)GazeCalibrationPoint.Center].SetWeights(center, channels.Count); samples[(int)GazeCalibrationPoint.CenterLeft].SetWeights(left, channels.Count); samples[(int)GazeCalibrationPoint.CenterRight].SetWeights(right, channels.Count); samples[(int)GazeCalibrationPoint.UpCenter].SetWeights(up, channels.Count); samples[(int)GazeCalibrationPoint.DownCenter].SetWeights(down, channels.Count); return GenerateCornersFromCardinals(); } public bool CopySample( GazeCalibrationPoint source, GazeCalibrationPoint destination, bool mirrorLeftRight) { if (!IsSampleCaptured(source)) return false; var destinationIndex = (int)destination; if (destinationIndex < 0 || destinationIndex >= 9) return false; var sourceWeights = GetSampleWeightsCopy(source); if (!mirrorLeftRight) { samples[destinationIndex].SetWeights(sourceWeights, channels.Count); return true; } var mirrored = new float[channels.Count]; for (var i = 0; i < mirrored.Length; i++) { var counterpart = FindMirroredChannelIndex(i); mirrored[i] = sourceWeights[counterpart]; } samples[destinationIndex].SetWeights(mirrored, channels.Count); return true; } public void SetDirectionLimits(float left, float right, float down, float up) { minYaw = SanitizeNegativeDirectionLimit(left, DefaultMinYaw); maxYaw = SanitizePositiveDirectionLimit(right, DefaultMaxYaw); minPitch = SanitizeNegativeDirectionLimit(down, DefaultMinPitch); maxPitch = SanitizePositiveDirectionLimit(up, DefaultMaxPitch); } public void SetSourceMesh(Mesh mesh) { sourceMesh = mesh; } public bool IsCompatibleWith(Mesh mesh) { return mesh != null && (sourceMesh == null || sourceMesh == mesh); } public bool SyncEyeLookChannels(SkinnedMeshRenderer renderer) { var mesh = renderer != null ? renderer.sharedMesh : null; if (mesh == null) return false; var previousChannels = new List(channels); var synchronized = new List(); var matchedEyeChannelCount = 0; for (var standardIndex = 0; standardIndex < StandardEyeLookNames.Length; standardIndex++) { var standardName = StandardEyeLookNames[standardIndex]; var meshName = FindBlendShapeName(mesh, standardName); if (string.IsNullOrEmpty(meshName)) continue; var existing = FindChannel(previousChannels, meshName); synchronized.Add(existing ?? new GazeBlendShapeChannel(meshName)); matchedEyeChannelCount++; } if (matchedEyeChannelCount != StandardEyeLookNames.Length) return false; // Preserve explicitly configured correctives when resyncing the eight eye channels. for (var i = 0; i < previousChannels.Count; i++) { var channel = previousChannels[i]; if (channel != null && channel.Usage == GazeChannelUsage.Corrective && !ContainsChannel(synchronized, channel.BlendShapeName)) synchronized.Add(channel); } if (synchronized.Count == 0) return false; channels = synchronized; EnsureGrid(); InvalidateSamples(); ResizeSamples(); UpdateChannelLayoutSignature(); sourceMesh = mesh; return true; } public bool CaptureSample(GazeCalibrationPoint point, SkinnedMeshRenderer renderer) { if (renderer == null || renderer.sharedMesh == null || channels.Count == 0 || !IsCompatibleWith(renderer.sharedMesh)) return false; if (sourceMesh == null) sourceMesh = renderer.sharedMesh; var sampleIndex = (int)point; if (sampleIndex < 0 || sampleIndex >= 9) return false; EnsureGrid(); var values = new float[channels.Count]; for (var i = 0; i < channels.Count; i++) { var channel = channels[i]; var index = channel != null ? FindBlendShapeIndex(renderer.sharedMesh, channel.BlendShapeName) : -1; if (index < 0) return false; values[i] = renderer.GetBlendShapeWeight(index); } samples[sampleIndex].SetWeights(values, channels.Count); return true; } public void SetSample(GazeCalibrationPoint point, IReadOnlyList weights) { EnsureGrid(); var sampleIndex = (int)point; if (sampleIndex < 0 || sampleIndex >= samples.Count) throw new ArgumentOutOfRangeException(nameof(point), point, "Calibration point must be in the 3x3 grid."); if (weights == null || weights.Count != channels.Count) throw new ArgumentException("A sample must contain exactly one weight per configured channel.", nameof(weights)); for (var channelIndex = 0; channelIndex < weights.Count; channelIndex++) { if (!IsFinite(weights[channelIndex])) throw new ArgumentException("Calibration sample weights must be finite.", nameof(weights)); } samples[sampleIndex].SetWeights(weights, channels.Count); } public float GetSampleWeight(GazeCalibrationPoint point, int channelIndex) { EnsureGrid(); var sampleIndex = (int)point; return sampleIndex >= 0 && sampleIndex < samples.Count && channelIndex >= 0 && channelIndex < channels.Count ? samples[sampleIndex].GetWeight(channelIndex) : 0f; } public void ReplaceChannels(IReadOnlyList replacements) { if (channels == null) channels = new List(); channels.Clear(); if (replacements != null) { for (var i = 0; i < replacements.Count; i++) { var channel = replacements[i]; if (channel != null) channels.Add(new GazeBlendShapeChannel( channel.BlendShapeName, channel.Usage, channel.CorrectiveBlendMode)); } } EnsureGrid(); InvalidateSamples(); ResizeSamples(); UpdateChannelLayoutSignature(); } public bool TryEvaluate( GazeDirectionResult direction, bool enableCorrectives, float[] output) { if (output == null || output.Length < channels.Count || !IsFullyCalibrated) return false; EnsureGrid(); GazeDirectionSolver.Evaluate3x3( samples, channels.Count, direction.NormalizedYaw, direction.NormalizedPitch, output); for (var i = 0; i < channels.Count; i++) { if (!IsFinite(output[i])) return false; } if (!enableCorrectives) { for (var i = 0; i < channels.Count; i++) { if (channels[i] != null && channels[i].Usage == GazeChannelUsage.Corrective) output[i] = float.NaN; } } return true; } public int FindBlendShapeIndex(Mesh mesh, int channelIndex) { return mesh != null && channelIndex >= 0 && channelIndex < channels.Count ? FindBlendShapeIndex(mesh, channels[channelIndex].BlendShapeName) : -1; } public static int FindBlendShapeIndex(Mesh mesh, string name) { if (mesh == null || string.IsNullOrWhiteSpace(name)) return -1; var exact = mesh.GetBlendShapeIndex(name); if (exact >= 0) return exact; for (var i = 0; i < mesh.blendShapeCount; i++) { var meshName = mesh.GetBlendShapeName(i); if (meshName.EndsWith(name, StringComparison.OrdinalIgnoreCase)) return i; } return -1; } private static string FindBlendShapeName(Mesh mesh, string standardName) { var index = FindBlendShapeIndex(mesh, standardName); return index >= 0 ? mesh.GetBlendShapeName(index) : null; } private static GazeBlendShapeChannel FindChannel( IReadOnlyList source, string name) { for (var i = 0; i < source.Count; i++) { var channel = source[i]; if (channel != null && string.Equals( channel.BlendShapeName, name, StringComparison.OrdinalIgnoreCase)) return channel; } return null; } private static bool ContainsChannel( IReadOnlyList source, string name) { return FindChannel(source, name) != null; } private void SetGeneratedCorner( GazeCalibrationPoint point, IReadOnlyList vertical, IReadOnlyList horizontal, IReadOnlyList center) { var weights = new float[channels.Count]; for (var i = 0; i < weights.Length; i++) { var value = vertical[i] + horizontal[i] - center[i]; weights[i] = ClampGeneratedWeight(i, value); } samples[(int)point].SetWeights(weights, channels.Count); } private float ClampGeneratedWeight(int channelIndex, float value) { return channels[channelIndex] != null && channels[channelIndex].Usage == GazeChannelUsage.Corrective ? Mathf.Clamp(value, -100f, 100f) : Mathf.Clamp(value, 0f, 100f); } private int FindChannelIndexByNormalizedSuffix(string standardName) { var normalizedSuffix = NormalizeChannelName(standardName); for (var i = 0; i < channels.Count; i++) { var channel = channels[i]; var normalizedName = NormalizeChannelName(channel != null ? channel.BlendShapeName : null); if (normalizedName.EndsWith(normalizedSuffix, StringComparison.Ordinal)) return i; } return -1; } private int FindMirroredChannelIndex(int channelIndex) { if (channelIndex < 0 || channelIndex >= channels.Count || channels[channelIndex] == null) return channelIndex; const string leftSuffix = "left"; const string rightSuffix = "right"; var normalizedName = NormalizeChannelName(channels[channelIndex].BlendShapeName); string counterpartName; if (normalizedName.EndsWith(leftSuffix, StringComparison.Ordinal)) { counterpartName = normalizedName.Substring(0, normalizedName.Length - leftSuffix.Length) + rightSuffix; } else if (normalizedName.EndsWith(rightSuffix, StringComparison.Ordinal)) { counterpartName = normalizedName.Substring(0, normalizedName.Length - rightSuffix.Length) + leftSuffix; } else { return channelIndex; } for (var i = 0; i < channels.Count; i++) { var channel = channels[i]; if (channel != null && string.Equals( NormalizeChannelName(channel.BlendShapeName), counterpartName, StringComparison.Ordinal)) return i; } return channelIndex; } private static string NormalizeChannelName(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; var builder = new StringBuilder(value.Length); for (var i = 0; i < value.Length; i++) { if (char.IsLetterOrDigit(value[i])) builder.Append(char.ToLowerInvariant(value[i])); } return builder.ToString(); } private void OnEnable() { if (channels == null) channels = new List(); EnsureGrid(); ResizeSamples(); if (string.IsNullOrEmpty(channelLayoutSignature)) UpdateChannelLayoutSignature(); } private void OnValidate() { minYaw = SanitizeNegativeDirectionLimit(minYaw, DefaultMinYaw); maxYaw = SanitizePositiveDirectionLimit(maxYaw, DefaultMaxYaw); minPitch = SanitizeNegativeDirectionLimit(minPitch, DefaultMinPitch); maxPitch = SanitizePositiveDirectionLimit(maxPitch, DefaultMaxPitch); if (channels == null) channels = new List(); else channels.RemoveAll(channel => channel == null); EnsureGrid(); var currentSignature = BuildChannelLayoutSignature(); if (!string.IsNullOrEmpty(channelLayoutSignature) && !string.Equals(channelLayoutSignature, currentSignature, StringComparison.Ordinal)) InvalidateSamples(); ResizeSamples(); channelLayoutSignature = currentSignature; } private static float SanitizeNegativeDirectionLimit(float value, float fallback) { if (float.IsNaN(value) || float.IsInfinity(value)) value = fallback; return Mathf.Clamp(value, -90f, -0.1f); } private static float SanitizePositiveDirectionLimit(float value, float fallback) { if (float.IsNaN(value) || float.IsInfinity(value)) value = fallback; return Mathf.Clamp(value, 0.1f, 90f); } private static bool IsFinite(float value) { return !float.IsNaN(value) && !float.IsInfinity(value); } private void EnsureGrid() { if (samples == null) samples = new List(9); while (samples.Count < 9) samples.Add(new GazeCalibrationSample((GazeCalibrationPoint)samples.Count)); if (samples.Count > 9) samples.RemoveRange(9, samples.Count - 9); for (var i = 0; i < 9; i++) { if (samples[i] == null) samples[i] = new GazeCalibrationSample((GazeCalibrationPoint)i); else samples[i].SetPoint((GazeCalibrationPoint)i); } } private void ResizeSamples() { for (var i = 0; i < samples.Count; i++) samples[i].Resize(channels.Count); } private void InvalidateSamples() { for (var i = 0; i < samples.Count; i++) samples[i]?.Invalidate(); } private void UpdateChannelLayoutSignature() { channelLayoutSignature = BuildChannelLayoutSignature(); } private string BuildChannelLayoutSignature() { var builder = new StringBuilder(channels.Count * 24); for (var i = 0; i < channels.Count; i++) { var channel = channels[i]; builder.Append(channel != null ? channel.BlendShapeName : ""); builder.Append('|'); builder.Append(channel != null ? (int)channel.Usage : -1); builder.Append('|'); builder.Append(channel != null ? (int)channel.CorrectiveBlendMode : -1); builder.Append(';'); } return builder.ToString(); } } }