using System; using System.Collections.Generic; using UnityEngine; namespace Streamingle.Gaze.Editor { /// /// Result of an editor-only gaze-origin estimation pass. /// internal readonly struct GazeOriginEstimate { internal GazeOriginEstimate( Vector3 worldPosition, Vector3 leftEyeWorldPosition, Vector3 rightEyeWorldPosition, bool usedEyeBlendShapes, int leftVertexCount, int rightVertexCount, string message) { WorldPosition = worldPosition; LeftEyeWorldPosition = leftEyeWorldPosition; RightEyeWorldPosition = rightEyeWorldPosition; UsedEyeBlendShapes = usedEyeBlendShapes; LeftVertexCount = leftVertexCount; RightVertexCount = rightVertexCount; Message = message; } internal Vector3 WorldPosition { get; } internal Vector3 LeftEyeWorldPosition { get; } internal Vector3 RightEyeWorldPosition { get; } internal bool UsedEyeBlendShapes { get; } internal int LeftVertexCount { get; } internal int RightVertexCount { get; } internal string Message { get; } } /// /// Estimates the midpoint between the eyes from the vertices influenced by the configured /// ARKit eyeLook blend shapes. This is deliberately editor-only: BakeMesh is never used by /// the runtime gaze driver. /// internal static class GazeOriginEstimator { private const float MinimumDelta = 1e-7f; private const int MinimumVerticesPerEye = 3; internal static GazeOriginEstimate Estimate( SkinnedMeshRenderer renderer, IReadOnlyList configuredChannelNames) { if (renderer == null) { return new GazeOriginEstimate( Vector3.zero, Vector3.zero, Vector3.zero, false, 0, 0, "Target Renderer is not assigned."); } Mesh sourceMesh = renderer.sharedMesh; if (sourceMesh == null || sourceMesh.vertexCount == 0) { return Fallback(renderer, "The renderer has no readable shared mesh."); } var leftShapeIndices = new HashSet(); var rightShapeIndices = new HashSet(); CollectShapeIndices(sourceMesh, configuredChannelNames, leftShapeIndices, rightShapeIndices); if (leftShapeIndices.Count == 0 || rightShapeIndices.Count == 0) { return Fallback( renderer, "Could not find configured eyeLook blend shapes for both eyes. Used Renderer bounds center."); } int vertexCount = sourceMesh.vertexCount; var leftMaximumDeltas = new float[vertexCount]; var rightMaximumDeltas = new float[vertexCount]; var deltaVertices = new Vector3[vertexCount]; var deltaNormals = new Vector3[vertexCount]; var deltaTangents = new Vector3[vertexCount]; AccumulateMaximumDeltas( sourceMesh, leftShapeIndices, leftMaximumDeltas, deltaVertices, deltaNormals, deltaTangents); AccumulateMaximumDeltas( sourceMesh, rightShapeIndices, rightMaximumDeltas, deltaVertices, deltaNormals, deltaTangents); Mesh bakedMesh = null; try { bakedMesh = new Mesh { name = "GazeOriginEstimator_TemporaryMesh" }; renderer.BakeMesh(bakedMesh); Vector3[] neutralVertices = bakedMesh.vertices; if (neutralVertices == null || neutralVertices.Length != vertexCount) { return Fallback(renderer, "BakeMesh vertex count did not match the source mesh."); } bool hasLeft = TryCalculateWeightedCentroid( renderer.transform, neutralVertices, leftMaximumDeltas, out Vector3 leftCenter, out int leftVertexCount); bool hasRight = TryCalculateWeightedCentroid( renderer.transform, neutralVertices, rightMaximumDeltas, out Vector3 rightCenter, out int rightVertexCount); if (!hasLeft || !hasRight) { return Fallback( renderer, "Eye blend shapes did not contain enough significant vertices. Used Renderer bounds center.", leftVertexCount, rightVertexCount); } // Deliberately give both eyes equal influence. A larger or stronger blend shape on one // side must not pull the gaze origin away from the center of the face. Vector3 midpoint = (leftCenter + rightCenter) * 0.5f; return new GazeOriginEstimate( midpoint, leftCenter, rightCenter, true, leftVertexCount, rightVertexCount, $"Estimated from {leftVertexCount} left-eye and {rightVertexCount} right-eye vertices."); } catch (Exception exception) { return Fallback(renderer, $"Eye vertex estimation failed: {exception.Message}"); } finally { if (bakedMesh != null) { UnityEngine.Object.DestroyImmediate(bakedMesh); } } } internal static int FindBlendShapeIndex(Mesh mesh, string configuredName) { if (mesh == null || string.IsNullOrWhiteSpace(configuredName)) { return -1; } string normalizedConfiguredName = Normalize(configuredName); int suffixMatch = -1; for (int index = 0; index < mesh.blendShapeCount; index++) { string meshName = mesh.GetBlendShapeName(index); if (string.Equals(meshName, configuredName, StringComparison.OrdinalIgnoreCase)) { return index; } if (meshName.EndsWith(configuredName, StringComparison.OrdinalIgnoreCase)) { suffixMatch = index; continue; } string normalizedMeshName = Normalize(meshName); if (normalizedMeshName == normalizedConfiguredName) { return index; } if (normalizedMeshName.EndsWith(normalizedConfiguredName, StringComparison.Ordinal)) { suffixMatch = index; } } return suffixMatch; } private static void CollectShapeIndices( Mesh mesh, IReadOnlyList configuredChannelNames, ISet leftShapeIndices, ISet rightShapeIndices) { if (configuredChannelNames != null) { for (int index = 0; index < configuredChannelNames.Count; index++) { string channelName = configuredChannelNames[index]; int blendShapeIndex = FindBlendShapeIndex(mesh, channelName); if (blendShapeIndex < 0) { continue; } string sideName = string.IsNullOrWhiteSpace(channelName) ? mesh.GetBlendShapeName(blendShapeIndex) : channelName; if (IsLeftChannel(sideName)) { leftShapeIndices.Add(blendShapeIndex); } else if (IsRightChannel(sideName)) { rightShapeIndices.Add(blendShapeIndex); } } } // A newly created profile has no bindings yet. Scanning the mesh for the standard suffixes // still gives Auto Create a useful result before the user presses Sync Channels. if (leftShapeIndices.Count > 0 && rightShapeIndices.Count > 0) { return; } for (int index = 0; index < mesh.blendShapeCount; index++) { string name = mesh.GetBlendShapeName(index); string normalizedName = Normalize(name); if (!normalizedName.Contains("eyelook")) { continue; } if (IsLeftChannel(name)) { leftShapeIndices.Add(index); } else if (IsRightChannel(name)) { rightShapeIndices.Add(index); } } } private static void AccumulateMaximumDeltas( Mesh mesh, IEnumerable shapeIndices, float[] maximumDeltas, Vector3[] deltaVertices, Vector3[] deltaNormals, Vector3[] deltaTangents) { foreach (int shapeIndex in shapeIndices) { int frameCount = mesh.GetBlendShapeFrameCount(shapeIndex); for (int frameIndex = 0; frameIndex < frameCount; frameIndex++) { mesh.GetBlendShapeFrameVertices( shapeIndex, frameIndex, deltaVertices, deltaNormals, deltaTangents); for (int vertexIndex = 0; vertexIndex < deltaVertices.Length; vertexIndex++) { float magnitude = deltaVertices[vertexIndex].magnitude; if (magnitude > maximumDeltas[vertexIndex]) { maximumDeltas[vertexIndex] = magnitude; } } } } } private static bool TryCalculateWeightedCentroid( Transform rendererTransform, IReadOnlyList neutralVertices, IReadOnlyList maximumDeltas, out Vector3 worldCentroid, out int includedVertexCount) { var positiveDeltas = new List(); for (int index = 0; index < maximumDeltas.Count; index++) { float delta = maximumDeltas[index]; if (delta > MinimumDelta && IsFinite(delta)) { positiveDeltas.Add(delta); } } if (positiveDeltas.Count < MinimumVerticesPerEye) { worldCentroid = Vector3.zero; includedVertexCount = 0; return false; } positiveDeltas.Sort(); float median = Percentile(positiveDeltas, 0.5f); float percentile95 = Percentile(positiveDeltas, 0.95f); float percentile99 = Percentile(positiveDeltas, 0.99f); // Remove tiny cross-face noise and reject pathological spikes. Valid high-motion eye // vertices are retained and winsorized so one vertex cannot dominate the centroid. float lowerThreshold = Mathf.Max(MinimumDelta, Mathf.Max(median * 0.05f, percentile95 * 0.015f)); float upperThreshold = Mathf.Max(percentile99 * 4f, lowerThreshold); float maximumWeight = Mathf.Max(percentile95 * 2f, lowerThreshold); Vector3 weightedPosition = Vector3.zero; float weightSum = 0f; includedVertexCount = 0; for (int index = 0; index < maximumDeltas.Count; index++) { float delta = maximumDeltas[index]; if (!IsFinite(delta) || delta < lowerThreshold || delta > upperThreshold) { continue; } float weight = Mathf.Min(delta, maximumWeight); weightedPosition += rendererTransform.TransformPoint(neutralVertices[index]) * weight; weightSum += weight; includedVertexCount++; } if (includedVertexCount < MinimumVerticesPerEye || weightSum <= Mathf.Epsilon) { worldCentroid = Vector3.zero; return false; } worldCentroid = weightedPosition / weightSum; return IsFinite(worldCentroid); } private static float Percentile(IReadOnlyList sortedValues, float percentile) { if (sortedValues.Count == 0) { return 0f; } float position = Mathf.Clamp01(percentile) * (sortedValues.Count - 1); int lowerIndex = Mathf.FloorToInt(position); int upperIndex = Mathf.CeilToInt(position); return Mathf.Lerp(sortedValues[lowerIndex], sortedValues[upperIndex], position - lowerIndex); } private static GazeOriginEstimate Fallback( SkinnedMeshRenderer renderer, string message, int leftVertexCount = 0, int rightVertexCount = 0) { Vector3 center = renderer != null ? renderer.bounds.center : Vector3.zero; return new GazeOriginEstimate( center, center, center, false, leftVertexCount, rightVertexCount, message); } private static bool IsLeftChannel(string name) { return Normalize(name).EndsWith("left", StringComparison.Ordinal); } private static bool IsRightChannel(string name) { return Normalize(name).EndsWith("right", StringComparison.Ordinal); } 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 static bool IsFinite(Vector3 value) { return IsFinite(value.x) && IsFinite(value.y) && IsFinite(value.z); } private static bool IsFinite(float value) { return !float.IsNaN(value) && !float.IsInfinity(value); } } }