feat: initialize Streamingle Utilities package
This commit is contained in:
@@ -0,0 +1,749 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using UnityEngine;
|
||||
|
||||
[assembly: InternalsVisibleTo("Streamingle.Gaze.Editor")]
|
||||
[assembly: InternalsVisibleTo("Streamingle.Gaze.Tests.Editor")]
|
||||
|
||||
namespace Streamingle.Gaze
|
||||
{
|
||||
[ExecuteAlways]
|
||||
[DisallowMultipleComponent]
|
||||
[DefaultExecutionOrder(10000)]
|
||||
public sealed class BlendshapeGazeDriver : MonoBehaviour
|
||||
{
|
||||
private const float MinimumInfluence = 0.0001f;
|
||||
private const float ExternalWriteTolerance = 0.001f;
|
||||
|
||||
[Header("Required")]
|
||||
[SerializeField]
|
||||
private SkinnedMeshRenderer targetRenderer;
|
||||
|
||||
[SerializeField]
|
||||
private BlendshapeGazeProfile profile;
|
||||
|
||||
[SerializeField]
|
||||
[Tooltip("Eye midpoint. Its position is used as the gaze origin.")]
|
||||
private Transform gazeOrigin;
|
||||
|
||||
[SerializeField]
|
||||
[Tooltip("Optional forward/up fallback when Gaze Origin is not assigned. Gaze Origin rotation is authoritative when present.")]
|
||||
private Transform headReference;
|
||||
|
||||
[Header("Default Target")]
|
||||
[SerializeField]
|
||||
[Tooltip("Optional output camera override. Timeline clips with no target use this before Camera.main.")]
|
||||
private Camera targetCamera;
|
||||
|
||||
[SerializeField]
|
||||
[Tooltip("Optional fallback target when Timeline is not driving this component.")]
|
||||
private Transform defaultTarget;
|
||||
|
||||
[SerializeField]
|
||||
private bool defaultToMainCamera;
|
||||
|
||||
[SerializeField, Range(0f, 1f)]
|
||||
private float defaultInfluence;
|
||||
|
||||
[SerializeField]
|
||||
private bool defaultCorrectives;
|
||||
|
||||
[Header("Camera Cut")]
|
||||
[SerializeField]
|
||||
private GazeCutResponse cutResponse = GazeCutResponse.Snap;
|
||||
|
||||
[SerializeField, Min(0f)]
|
||||
private float cutTransitionDuration = 0.1f;
|
||||
|
||||
[SerializeField, Range(0f, 180f)]
|
||||
[Tooltip("A target-direction jump at or above this angle is treated as a camera cut. Set 0 to disable angle detection.")]
|
||||
private float cutDetectionAngle = 15f;
|
||||
|
||||
[Header("Debug Gizmos")]
|
||||
[SerializeField]
|
||||
private bool drawGizmos = true;
|
||||
|
||||
[SerializeField, Min(0.01f)]
|
||||
private float gizmoDistance = 0.35f;
|
||||
|
||||
private readonly Dictionary<int, GazeTimelineState> timelineStates =
|
||||
new Dictionary<int, GazeTimelineState>();
|
||||
|
||||
private int[] blendShapeIndices = Array.Empty<int>();
|
||||
private float[] baselineWeights = Array.Empty<float>();
|
||||
private float[] evaluatedWeights = Array.Empty<float>();
|
||||
private float[] lastAppliedWeights = Array.Empty<float>();
|
||||
private string[] cachedChannelNames = Array.Empty<string>();
|
||||
private SkinnedMeshRenderer cachedRenderer;
|
||||
private Mesh cachedMesh;
|
||||
private BlendshapeGazeProfile cachedProfile;
|
||||
private bool hasAppliedWeights;
|
||||
private bool hasRuntimeState;
|
||||
private GazeTimelineState runtimeState;
|
||||
private bool hasPreviousDirection;
|
||||
private Vector3 previousDesiredDirection;
|
||||
private UnityEngine.Object previousTargetIdentity;
|
||||
private bool cutTransitionActive;
|
||||
private Vector3 cutTransitionFromDirection;
|
||||
private float cutTransitionStartTime;
|
||||
private int calibrationPreviewOverrideCount;
|
||||
|
||||
public SkinnedMeshRenderer TargetRenderer
|
||||
{
|
||||
get => targetRenderer;
|
||||
set
|
||||
{
|
||||
if (targetRenderer == value)
|
||||
return;
|
||||
ReleaseWeights();
|
||||
targetRenderer = value;
|
||||
InvalidateCache();
|
||||
}
|
||||
}
|
||||
|
||||
public BlendshapeGazeProfile Profile
|
||||
{
|
||||
get => profile;
|
||||
set
|
||||
{
|
||||
if (profile == value)
|
||||
return;
|
||||
ReleaseWeights();
|
||||
profile = value;
|
||||
InvalidateCache();
|
||||
}
|
||||
}
|
||||
|
||||
public Transform GazeOrigin
|
||||
{
|
||||
get => gazeOrigin;
|
||||
set => gazeOrigin = value;
|
||||
}
|
||||
|
||||
public Transform HeadReference
|
||||
{
|
||||
get => headReference;
|
||||
set => headReference = value;
|
||||
}
|
||||
|
||||
public Camera TargetCamera
|
||||
{
|
||||
get => targetCamera;
|
||||
set => targetCamera = value;
|
||||
}
|
||||
|
||||
public GazeDirectionResult LastDirection { get; private set; }
|
||||
public Vector3 LastOriginPosition { get; private set; }
|
||||
public Vector3 LastTargetPosition { get; private set; }
|
||||
public bool HasActiveRequest { get; private set; }
|
||||
|
||||
public Vector3 GetGazeOriginPosition()
|
||||
{
|
||||
if (gazeOrigin != null)
|
||||
return gazeOrigin.position;
|
||||
if (targetRenderer != null)
|
||||
return targetRenderer.bounds.center;
|
||||
return transform.position;
|
||||
}
|
||||
|
||||
public bool RebuildCache()
|
||||
{
|
||||
ReleaseWeights();
|
||||
InvalidateCache();
|
||||
return EnsureCache();
|
||||
}
|
||||
|
||||
private Transform DirectionReference => gazeOrigin != null
|
||||
? gazeOrigin
|
||||
: (headReference != null ? headReference : transform);
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (Application.IsPlaying(gameObject))
|
||||
{
|
||||
EvaluateNow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// ExecuteAlways receives the editor player loop requested by the
|
||||
// Timeline mixer. This is a stronger final ordering point than a
|
||||
// one-shot delayCall: Director animation has already updated the Head,
|
||||
// GazeOrigin and facial blendshapes before this high-order LateUpdate.
|
||||
if (!Application.isPlaying && timelineStates.Count > 0)
|
||||
EvaluateNow(true);
|
||||
#endif
|
||||
}
|
||||
|
||||
public void SetTimelineState(int sourceId, GazeTimelineState state)
|
||||
{
|
||||
state.Influence = SanitizeInfluence(state.Influence);
|
||||
state.CutTransitionDuration = SanitizeNonNegative(state.CutTransitionDuration);
|
||||
timelineStates[sourceId] = state;
|
||||
}
|
||||
|
||||
public void ClearTimelineState(int sourceId)
|
||||
{
|
||||
timelineStates.Remove(sourceId);
|
||||
if (timelineStates.Count == 0)
|
||||
{
|
||||
HasActiveRequest = false;
|
||||
ReleaseWeights();
|
||||
ResetDirectionHistory();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetRuntimeTarget(
|
||||
Transform target,
|
||||
float influence = 1f,
|
||||
bool enableCorrectives = false)
|
||||
{
|
||||
runtimeState = new GazeTimelineState
|
||||
{
|
||||
Target = target,
|
||||
UseMainCamera = target == null,
|
||||
Influence = SanitizeInfluence(influence),
|
||||
EnableCorrectives = enableCorrectives,
|
||||
ResetSmoothing = !hasRuntimeState
|
||||
};
|
||||
hasRuntimeState = true;
|
||||
}
|
||||
|
||||
public void SetRuntimeTargetPosition(
|
||||
Vector3 worldPosition,
|
||||
float influence = 1f,
|
||||
bool enableCorrectives = false)
|
||||
{
|
||||
runtimeState = new GazeTimelineState
|
||||
{
|
||||
TargetPosition = worldPosition,
|
||||
HasTargetPosition = true,
|
||||
Influence = SanitizeInfluence(influence),
|
||||
EnableCorrectives = enableCorrectives,
|
||||
ResetSmoothing = !hasRuntimeState
|
||||
};
|
||||
hasRuntimeState = true;
|
||||
}
|
||||
|
||||
public void ClearRuntimeTarget()
|
||||
{
|
||||
hasRuntimeState = false;
|
||||
if (timelineStates.Count == 0)
|
||||
{
|
||||
HasActiveRequest = false;
|
||||
ReleaseWeights();
|
||||
ResetDirectionHistory();
|
||||
}
|
||||
}
|
||||
|
||||
public void EvaluateNow(bool deterministic)
|
||||
{
|
||||
// Calibration owns the configured blendshape channels while its preview
|
||||
// session is active. Timeline/editor evaluation can still update its
|
||||
// request state, but must not overwrite (or restore over) that preview.
|
||||
if (calibrationPreviewOverrideCount > 0)
|
||||
{
|
||||
HasActiveRequest = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetActiveState(out var state, out var usesRuntimeState))
|
||||
{
|
||||
HasActiveRequest = false;
|
||||
ReleaseWeights();
|
||||
ResetDirectionHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
state.Deterministic |= deterministic;
|
||||
if (!TryResolveTarget(state, out var targetPosition, out var targetIdentity))
|
||||
{
|
||||
HasActiveRequest = false;
|
||||
ReleaseWeights();
|
||||
ResetDirectionHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!EnsureCache())
|
||||
{
|
||||
HasActiveRequest = false;
|
||||
ReleaseWeights();
|
||||
return;
|
||||
}
|
||||
|
||||
LastOriginPosition = GetGazeOriginPosition();
|
||||
LastTargetPosition = targetPosition;
|
||||
var desiredDirection = targetPosition - LastOriginPosition;
|
||||
if (desiredDirection.sqrMagnitude <= Mathf.Epsilon)
|
||||
{
|
||||
HasActiveRequest = false;
|
||||
ReleaseWeights();
|
||||
return;
|
||||
}
|
||||
desiredDirection.Normalize();
|
||||
|
||||
var effectiveDirection = ApplyCutResponse(
|
||||
desiredDirection,
|
||||
targetIdentity,
|
||||
state,
|
||||
state.Deterministic || !Application.isPlaying);
|
||||
var reference = DirectionReference;
|
||||
LastDirection = GazeDirectionSolver.Calculate(
|
||||
reference != null ? reference.rotation : Quaternion.identity,
|
||||
effectiveDirection,
|
||||
profile.MinYaw,
|
||||
profile.MaxYaw,
|
||||
profile.MinPitch,
|
||||
profile.MaxPitch);
|
||||
|
||||
if (!profile.TryEvaluate(LastDirection, state.EnableCorrectives, evaluatedWeights))
|
||||
{
|
||||
HasActiveRequest = false;
|
||||
ReleaseWeights();
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureExternalBaseline();
|
||||
ApplyWeights(Mathf.Clamp01(state.Influence));
|
||||
HasActiveRequest = true;
|
||||
|
||||
if (usesRuntimeState)
|
||||
runtimeState.ResetSmoothing = false;
|
||||
}
|
||||
|
||||
internal void BeginCalibrationPreviewOverride()
|
||||
{
|
||||
if (calibrationPreviewOverrideCount == 0)
|
||||
{
|
||||
// If gaze was already driving the renderer, return those channels to
|
||||
// their external facial baseline before the preview captures it.
|
||||
ReleaseWeights();
|
||||
ResetDirectionHistory();
|
||||
HasActiveRequest = false;
|
||||
}
|
||||
|
||||
calibrationPreviewOverrideCount++;
|
||||
}
|
||||
|
||||
internal void EndCalibrationPreviewOverride()
|
||||
{
|
||||
if (calibrationPreviewOverrideCount <= 0)
|
||||
return;
|
||||
|
||||
calibrationPreviewOverrideCount--;
|
||||
if (calibrationPreviewOverrideCount == 0)
|
||||
ResetDirectionHistory();
|
||||
}
|
||||
|
||||
internal void PrepareForTimelineEvaluation()
|
||||
{
|
||||
if (!isActiveAndEnabled || calibrationPreviewOverrideCount > 0)
|
||||
return;
|
||||
|
||||
// PlayableGraph PrepareFrame runs before Timeline animation outputs.
|
||||
// Restore the prior facial baseline here, then LateUpdate/editor final
|
||||
// evaluation can capture the newly sampled value even when it happens
|
||||
// to be numerically identical to our previous gaze result.
|
||||
ReleaseWeights();
|
||||
}
|
||||
|
||||
private bool TryGetActiveState(out GazeTimelineState state, out bool usesRuntimeState)
|
||||
{
|
||||
var found = false;
|
||||
var bestInfluence = MinimumInfluence;
|
||||
var bestSourceId = int.MaxValue;
|
||||
state = default;
|
||||
usesRuntimeState = false;
|
||||
|
||||
foreach (var pair in timelineStates)
|
||||
{
|
||||
var candidate = pair.Value;
|
||||
if (candidate.Influence <= MinimumInfluence)
|
||||
continue;
|
||||
|
||||
if (found && candidate.Influence < bestInfluence)
|
||||
continue;
|
||||
if (found && Mathf.Approximately(candidate.Influence, bestInfluence)
|
||||
&& pair.Key >= bestSourceId)
|
||||
continue;
|
||||
|
||||
state = candidate;
|
||||
bestInfluence = candidate.Influence;
|
||||
bestSourceId = pair.Key;
|
||||
found = true;
|
||||
}
|
||||
|
||||
if (found)
|
||||
return true;
|
||||
|
||||
if (hasRuntimeState && runtimeState.Influence > MinimumInfluence)
|
||||
{
|
||||
state = runtimeState;
|
||||
usesRuntimeState = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (defaultInfluence <= MinimumInfluence || (defaultTarget == null && !defaultToMainCamera))
|
||||
return false;
|
||||
|
||||
state = new GazeTimelineState
|
||||
{
|
||||
Target = defaultTarget,
|
||||
UseMainCamera = defaultTarget == null && defaultToMainCamera,
|
||||
Influence = defaultInfluence,
|
||||
EnableCorrectives = defaultCorrectives
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryResolveTarget(
|
||||
GazeTimelineState state,
|
||||
out Vector3 targetPosition,
|
||||
out UnityEngine.Object identity)
|
||||
{
|
||||
if (state.HasTargetPosition)
|
||||
{
|
||||
targetPosition = state.TargetPosition;
|
||||
identity = null;
|
||||
return IsFinite(targetPosition);
|
||||
}
|
||||
|
||||
if (state.HasTargetDirection)
|
||||
{
|
||||
var targetDirection = state.TargetDirection;
|
||||
if (!IsFinite(targetDirection) || targetDirection.sqrMagnitude <= Mathf.Epsilon)
|
||||
{
|
||||
targetPosition = default;
|
||||
identity = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
targetPosition = GetGazeOriginPosition() + targetDirection.normalized;
|
||||
identity = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
var target = state.Target;
|
||||
if (target == null && state.UseMainCamera)
|
||||
{
|
||||
var mainCamera = targetCamera != null ? targetCamera : Camera.main;
|
||||
target = mainCamera != null ? mainCamera.transform : null;
|
||||
}
|
||||
|
||||
if (target == null)
|
||||
{
|
||||
targetPosition = default;
|
||||
identity = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
targetPosition = target.position;
|
||||
identity = target;
|
||||
return IsFinite(targetPosition);
|
||||
}
|
||||
|
||||
private Vector3 ApplyCutResponse(
|
||||
Vector3 desiredDirection,
|
||||
UnityEngine.Object targetIdentity,
|
||||
GazeTimelineState state,
|
||||
bool deterministic)
|
||||
{
|
||||
var response = state.OverrideCutResponse ? state.CutResponse : cutResponse;
|
||||
var duration = state.OverrideCutResponse
|
||||
? state.CutTransitionDuration
|
||||
: cutTransitionDuration;
|
||||
|
||||
if (deterministic || state.ResetSmoothing || !hasPreviousDirection)
|
||||
{
|
||||
previousDesiredDirection = desiredDirection;
|
||||
previousTargetIdentity = targetIdentity;
|
||||
hasPreviousDirection = true;
|
||||
cutTransitionActive = false;
|
||||
return desiredDirection;
|
||||
}
|
||||
|
||||
var identityChanged = previousTargetIdentity != targetIdentity;
|
||||
// Zero disables pose-jump detection while still allowing an explicit target
|
||||
// identity change to count as a cut.
|
||||
var angleJump = cutDetectionAngle > 0f
|
||||
&& Vector3.Angle(previousDesiredDirection, desiredDirection) >= cutDetectionAngle;
|
||||
if (response == GazeCutResponse.Smooth && duration > Mathf.Epsilon
|
||||
&& (identityChanged || angleJump && !cutTransitionActive))
|
||||
{
|
||||
cutTransitionFromDirection = cutTransitionActive
|
||||
? EvaluateCutTransition(previousDesiredDirection, duration)
|
||||
: previousDesiredDirection;
|
||||
cutTransitionStartTime = Time.unscaledTime;
|
||||
cutTransitionActive = true;
|
||||
}
|
||||
|
||||
previousDesiredDirection = desiredDirection;
|
||||
previousTargetIdentity = targetIdentity;
|
||||
|
||||
if (response != GazeCutResponse.Smooth || duration <= Mathf.Epsilon)
|
||||
{
|
||||
cutTransitionActive = false;
|
||||
return desiredDirection;
|
||||
}
|
||||
|
||||
return EvaluateCutTransition(desiredDirection, duration);
|
||||
}
|
||||
|
||||
private Vector3 EvaluateCutTransition(Vector3 destination, float duration)
|
||||
{
|
||||
if (!cutTransitionActive)
|
||||
return destination;
|
||||
|
||||
var t = Mathf.Clamp01((Time.unscaledTime - cutTransitionStartTime) / duration);
|
||||
if (t >= 1f)
|
||||
{
|
||||
cutTransitionActive = false;
|
||||
return destination;
|
||||
}
|
||||
|
||||
return Vector3.Slerp(cutTransitionFromDirection, destination, t).normalized;
|
||||
}
|
||||
|
||||
private bool EnsureCache()
|
||||
{
|
||||
var mesh = targetRenderer != null ? targetRenderer.sharedMesh : null;
|
||||
if (targetRenderer == null || mesh == null || profile == null || profile.Channels.Count == 0
|
||||
|| !profile.IsCompatibleWith(mesh))
|
||||
return false;
|
||||
|
||||
if (cachedRenderer == targetRenderer && cachedMesh == mesh && cachedProfile == profile
|
||||
&& blendShapeIndices.Length == profile.Channels.Count && ChannelMappingMatches())
|
||||
return true;
|
||||
|
||||
ReleaseWeights();
|
||||
cachedRenderer = targetRenderer;
|
||||
cachedMesh = mesh;
|
||||
cachedProfile = profile;
|
||||
var count = profile.Channels.Count;
|
||||
blendShapeIndices = new int[count];
|
||||
baselineWeights = new float[count];
|
||||
evaluatedWeights = new float[count];
|
||||
lastAppliedWeights = new float[count];
|
||||
cachedChannelNames = new string[count];
|
||||
var resolvedIndices = new HashSet<int>();
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
cachedChannelNames[i] = profile.Channels[i].BlendShapeName;
|
||||
blendShapeIndices[i] = profile.FindBlendShapeIndex(mesh, i);
|
||||
if (blendShapeIndices[i] < 0 || !resolvedIndices.Add(blendShapeIndices[i]))
|
||||
{
|
||||
InvalidateCache();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CaptureExternalBaseline()
|
||||
{
|
||||
for (var i = 0; i < blendShapeIndices.Length; i++)
|
||||
{
|
||||
var current = targetRenderer.GetBlendShapeWeight(blendShapeIndices[i]);
|
||||
if (!hasAppliedWeights || Mathf.Abs(current - lastAppliedWeights[i]) > ExternalWriteTolerance)
|
||||
baselineWeights[i] = current;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyWeights(float influence)
|
||||
{
|
||||
for (var i = 0; i < blendShapeIndices.Length; i++)
|
||||
{
|
||||
var targetWeight = evaluatedWeights[i];
|
||||
var channel = profile.Channels[i];
|
||||
float result;
|
||||
if (float.IsNaN(targetWeight))
|
||||
{
|
||||
result = baselineWeights[i];
|
||||
}
|
||||
else if (channel.Usage == GazeChannelUsage.Corrective
|
||||
&& channel.CorrectiveBlendMode == GazeCorrectiveBlendMode.AdditiveFromCenter)
|
||||
{
|
||||
var centerWeight = profile.GetSampleWeight(GazeCalibrationPoint.Center, i);
|
||||
result = baselineWeights[i] + (targetWeight - centerWeight) * influence;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = Mathf.LerpUnclamped(baselineWeights[i], targetWeight, influence);
|
||||
}
|
||||
targetRenderer.SetBlendShapeWeight(blendShapeIndices[i], result);
|
||||
lastAppliedWeights[i] = result;
|
||||
}
|
||||
|
||||
hasAppliedWeights = true;
|
||||
}
|
||||
|
||||
private bool ChannelMappingMatches()
|
||||
{
|
||||
if (cachedChannelNames.Length != profile.Channels.Count)
|
||||
return false;
|
||||
for (var i = 0; i < cachedChannelNames.Length; i++)
|
||||
{
|
||||
var channel = profile.Channels[i];
|
||||
if (channel == null || !string.Equals(
|
||||
cachedChannelNames[i],
|
||||
channel.BlendShapeName,
|
||||
StringComparison.Ordinal))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ReleaseWeights()
|
||||
{
|
||||
if (!hasAppliedWeights || cachedRenderer == null)
|
||||
return;
|
||||
|
||||
// The old indices are only meaningful for the mesh they were built from.
|
||||
if (cachedRenderer.sharedMesh != cachedMesh)
|
||||
{
|
||||
hasAppliedWeights = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// If Animator/Timeline/a live receiver has written since our previous LateUpdate,
|
||||
// that current value is the new base. Otherwise restore the last base so stopping
|
||||
// the gaze track never leaves its final pose stuck on the face.
|
||||
for (var i = 0; i < blendShapeIndices.Length && i < baselineWeights.Length; i++)
|
||||
{
|
||||
var index = blendShapeIndices[i];
|
||||
if (index < 0)
|
||||
continue;
|
||||
|
||||
var current = cachedRenderer.GetBlendShapeWeight(index);
|
||||
if (Mathf.Abs(current - lastAppliedWeights[i]) > ExternalWriteTolerance)
|
||||
baselineWeights[i] = current;
|
||||
cachedRenderer.SetBlendShapeWeight(index, baselineWeights[i]);
|
||||
}
|
||||
|
||||
hasAppliedWeights = false;
|
||||
}
|
||||
|
||||
private void ResetDirectionHistory()
|
||||
{
|
||||
hasPreviousDirection = false;
|
||||
previousTargetIdentity = null;
|
||||
cutTransitionActive = false;
|
||||
}
|
||||
|
||||
private void InvalidateCache()
|
||||
{
|
||||
cachedRenderer = null;
|
||||
cachedMesh = null;
|
||||
cachedProfile = null;
|
||||
blendShapeIndices = Array.Empty<int>();
|
||||
baselineWeights = Array.Empty<float>();
|
||||
evaluatedWeights = Array.Empty<float>();
|
||||
lastAppliedWeights = Array.Empty<float>();
|
||||
cachedChannelNames = Array.Empty<string>();
|
||||
hasAppliedWeights = false;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
BlendshapeGazeMixerBehaviour.CancelEditorFinalEvaluation(this);
|
||||
#endif
|
||||
ReleaseWeights();
|
||||
timelineStates.Clear();
|
||||
ResetDirectionHistory();
|
||||
HasActiveRequest = false;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
defaultInfluence = SanitizeInfluence(defaultInfluence);
|
||||
cutTransitionDuration = SanitizeNonNegative(cutTransitionDuration);
|
||||
cutDetectionAngle = IsFinite(cutDetectionAngle)
|
||||
? Mathf.Clamp(cutDetectionAngle, 0f, 180f)
|
||||
: 15f;
|
||||
gizmoDistance = IsFinite(gizmoDistance)
|
||||
? Mathf.Max(0.01f, gizmoDistance)
|
||||
: 0.35f;
|
||||
ReleaseWeights();
|
||||
InvalidateCache();
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (!drawGizmos || profile == null)
|
||||
return;
|
||||
|
||||
var origin = gazeOrigin != null
|
||||
? gazeOrigin.position
|
||||
: (targetRenderer != null ? targetRenderer.bounds.center : transform.position);
|
||||
var reference = DirectionReference;
|
||||
var rotation = reference != null ? reference.rotation : Quaternion.identity;
|
||||
var distance = Mathf.Max(0.01f, gizmoDistance);
|
||||
|
||||
var downLeft = origin + rotation * GazeDirectionSolver.DirectionFromAngles(
|
||||
profile.MinYaw,
|
||||
profile.MinPitch) * distance;
|
||||
var downRight = origin + rotation * GazeDirectionSolver.DirectionFromAngles(
|
||||
profile.MaxYaw,
|
||||
profile.MinPitch) * distance;
|
||||
var upLeft = origin + rotation * GazeDirectionSolver.DirectionFromAngles(
|
||||
profile.MinYaw,
|
||||
profile.MaxPitch) * distance;
|
||||
var upRight = origin + rotation * GazeDirectionSolver.DirectionFromAngles(
|
||||
profile.MaxYaw,
|
||||
profile.MaxPitch) * distance;
|
||||
|
||||
Gizmos.color = new Color(0.2f, 0.9f, 1f, 0.8f);
|
||||
Gizmos.DrawWireSphere(origin, distance * 0.025f);
|
||||
Gizmos.DrawLine(origin, downLeft);
|
||||
Gizmos.DrawLine(origin, downRight);
|
||||
Gizmos.DrawLine(origin, upLeft);
|
||||
Gizmos.DrawLine(origin, upRight);
|
||||
Gizmos.DrawLine(downLeft, downRight);
|
||||
Gizmos.DrawLine(downRight, upRight);
|
||||
Gizmos.DrawLine(upRight, upLeft);
|
||||
Gizmos.DrawLine(upLeft, downLeft);
|
||||
|
||||
Gizmos.color = Color.green;
|
||||
Gizmos.DrawLine(origin, origin + rotation * Vector3.forward * distance);
|
||||
|
||||
if (!HasActiveRequest)
|
||||
return;
|
||||
|
||||
Gizmos.color = LastDirection.WasClamped ? Color.red : Color.cyan;
|
||||
Gizmos.DrawLine(origin, LastTargetPosition);
|
||||
Gizmos.DrawWireSphere(LastTargetPosition, distance * 0.035f);
|
||||
|
||||
var clampedDirection = rotation * GazeDirectionSolver.DirectionFromAngles(
|
||||
LastDirection.ClampedYaw,
|
||||
LastDirection.ClampedPitch);
|
||||
Gizmos.color = Color.yellow;
|
||||
Gizmos.DrawLine(origin, origin + clampedDirection * distance);
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector3 value)
|
||||
{
|
||||
return !float.IsNaN(value.x) && !float.IsInfinity(value.x)
|
||||
&& !float.IsNaN(value.y) && !float.IsInfinity(value.y)
|
||||
&& !float.IsNaN(value.z) && !float.IsInfinity(value.z);
|
||||
}
|
||||
|
||||
private static bool IsFinite(float value)
|
||||
{
|
||||
return !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
}
|
||||
|
||||
private static float SanitizeInfluence(float value)
|
||||
{
|
||||
return IsFinite(value) ? Mathf.Clamp01(value) : 0f;
|
||||
}
|
||||
|
||||
private static float SanitizeNonNegative(float value)
|
||||
{
|
||||
return IsFinite(value) ? Mathf.Max(0f, value) : 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 21992d28728ca584d85dd4fbbb72b02b
|
||||
@@ -0,0 +1,634 @@
|
||||
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<GazeBlendShapeChannel> channels = new List<GazeBlendShapeChannel>();
|
||||
|
||||
[Header("3x3 Calibration")]
|
||||
[SerializeField]
|
||||
private List<GazeCalibrationSample> samples = new List<GazeCalibrationSample>(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<GazeBlendShapeChannel> Channels => channels;
|
||||
public IReadOnlyList<GazeCalibrationSample> 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<float>();
|
||||
|
||||
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<GazeBlendShapeChannel>(channels);
|
||||
var synchronized = new List<GazeBlendShapeChannel>();
|
||||
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<float> 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<GazeBlendShapeChannel> replacements)
|
||||
{
|
||||
if (channels == null)
|
||||
channels = new List<GazeBlendShapeChannel>();
|
||||
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<GazeBlendShapeChannel> 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<GazeBlendShapeChannel> source,
|
||||
string name)
|
||||
{
|
||||
return FindChannel(source, name) != null;
|
||||
}
|
||||
|
||||
private void SetGeneratedCorner(
|
||||
GazeCalibrationPoint point,
|
||||
IReadOnlyList<float> vertical,
|
||||
IReadOnlyList<float> horizontal,
|
||||
IReadOnlyList<float> 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<GazeBlendShapeChannel>();
|
||||
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<GazeBlendShapeChannel>();
|
||||
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<GazeCalibrationSample>(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 : "<null>");
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 27e03e57ecb32e245b2ab8d0a44ba805
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Streamingle.Gaze
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure direction and 3x3 interpolation helpers. Kept independent of the
|
||||
/// component so they can also be reused by a future real-time input source.
|
||||
/// </summary>
|
||||
public static class GazeDirectionSolver
|
||||
{
|
||||
private const float MinimumAngleRange = 0.001f;
|
||||
|
||||
public static GazeDirectionResult Calculate(
|
||||
Quaternion referenceRotation,
|
||||
Vector3 worldDirection,
|
||||
float minYaw,
|
||||
float maxYaw,
|
||||
float minPitch,
|
||||
float maxPitch)
|
||||
{
|
||||
if (worldDirection.sqrMagnitude <= Mathf.Epsilon)
|
||||
worldDirection = referenceRotation * Vector3.forward;
|
||||
else
|
||||
worldDirection.Normalize();
|
||||
|
||||
var localDirection = Quaternion.Inverse(referenceRotation) * worldDirection;
|
||||
if (localDirection.sqrMagnitude <= Mathf.Epsilon)
|
||||
localDirection = Vector3.forward;
|
||||
else
|
||||
localDirection.Normalize();
|
||||
|
||||
var planarLength = Mathf.Sqrt(
|
||||
localDirection.x * localDirection.x + localDirection.z * localDirection.z);
|
||||
var rawYaw = Mathf.Atan2(localDirection.x, localDirection.z) * Mathf.Rad2Deg;
|
||||
var rawPitch = Mathf.Atan2(localDirection.y, planarLength) * Mathf.Rad2Deg;
|
||||
|
||||
NormalizeRanges(ref minYaw, ref maxYaw, ref minPitch, ref maxPitch);
|
||||
var clampedYaw = Mathf.Clamp(rawYaw, minYaw, maxYaw);
|
||||
var clampedPitch = Mathf.Clamp(rawPitch, minPitch, maxPitch);
|
||||
|
||||
return new GazeDirectionResult(
|
||||
worldDirection,
|
||||
localDirection,
|
||||
rawYaw,
|
||||
rawPitch,
|
||||
clampedYaw,
|
||||
clampedPitch,
|
||||
NormalizeAsymmetric(clampedYaw, minYaw, maxYaw),
|
||||
NormalizeAsymmetric(clampedPitch, minPitch, maxPitch));
|
||||
}
|
||||
|
||||
public static Vector3 DirectionFromAngles(float yaw, float pitch)
|
||||
{
|
||||
return Quaternion.Euler(-pitch, yaw, 0f) * Vector3.forward;
|
||||
}
|
||||
|
||||
public static float NormalizeAsymmetric(float value, float negativeLimit, float positiveLimit)
|
||||
{
|
||||
if (value < 0f)
|
||||
return Mathf.Clamp(value / Mathf.Max(MinimumAngleRange, -negativeLimit), -1f, 0f);
|
||||
|
||||
return Mathf.Clamp(value / Mathf.Max(MinimumAngleRange, positiveLimit), 0f, 1f);
|
||||
}
|
||||
|
||||
public static void Evaluate3x3(
|
||||
IReadOnlyList<GazeCalibrationSample> samples,
|
||||
int channelCount,
|
||||
float normalizedYaw,
|
||||
float normalizedPitch,
|
||||
float[] output)
|
||||
{
|
||||
if (output == null || output.Length < channelCount)
|
||||
return;
|
||||
|
||||
normalizedYaw = Mathf.Clamp(normalizedYaw, -1f, 1f);
|
||||
normalizedPitch = Mathf.Clamp(normalizedPitch, -1f, 1f);
|
||||
|
||||
var x0 = normalizedYaw < 0f ? 0 : 1;
|
||||
var x1 = x0 + 1;
|
||||
var y0 = normalizedPitch < 0f ? 0 : 1;
|
||||
var y1 = y0 + 1;
|
||||
var tx = normalizedYaw < 0f ? normalizedYaw + 1f : normalizedYaw;
|
||||
var ty = normalizedPitch < 0f ? normalizedPitch + 1f : normalizedPitch;
|
||||
|
||||
var bottomLeft = GetSample(samples, y0 * 3 + x0);
|
||||
var bottomRight = GetSample(samples, y0 * 3 + x1);
|
||||
var topLeft = GetSample(samples, y1 * 3 + x0);
|
||||
var topRight = GetSample(samples, y1 * 3 + x1);
|
||||
|
||||
for (var channel = 0; channel < channelCount; channel++)
|
||||
{
|
||||
var bottom = Mathf.Lerp(
|
||||
bottomLeft != null ? bottomLeft.GetWeight(channel) : 0f,
|
||||
bottomRight != null ? bottomRight.GetWeight(channel) : 0f,
|
||||
tx);
|
||||
var top = Mathf.Lerp(
|
||||
topLeft != null ? topLeft.GetWeight(channel) : 0f,
|
||||
topRight != null ? topRight.GetWeight(channel) : 0f,
|
||||
tx);
|
||||
output[channel] = Mathf.Lerp(bottom, top, ty);
|
||||
}
|
||||
}
|
||||
|
||||
private static GazeCalibrationSample GetSample(
|
||||
IReadOnlyList<GazeCalibrationSample> samples,
|
||||
int index)
|
||||
{
|
||||
return samples != null && index >= 0 && index < samples.Count ? samples[index] : null;
|
||||
}
|
||||
|
||||
private static void NormalizeRanges(
|
||||
ref float minYaw,
|
||||
ref float maxYaw,
|
||||
ref float minPitch,
|
||||
ref float maxPitch)
|
||||
{
|
||||
minYaw = Mathf.Min(-MinimumAngleRange, minYaw);
|
||||
maxYaw = Mathf.Max(MinimumAngleRange, maxYaw);
|
||||
minPitch = Mathf.Min(-MinimumAngleRange, minPitch);
|
||||
maxPitch = Mathf.Max(MinimumAngleRange, maxPitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db711509b9605ce4eb75578c5cc07215
|
||||
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Streamingle.Gaze
|
||||
{
|
||||
/// <summary>
|
||||
/// Row-major layout of the calibration grid. Down/Up describe gaze pitch and
|
||||
/// Left/Right describe gaze yaw from the character's point of view.
|
||||
/// </summary>
|
||||
public enum GazeCalibrationPoint
|
||||
{
|
||||
DownLeft = 0,
|
||||
DownCenter = 1,
|
||||
DownRight = 2,
|
||||
CenterLeft = 3,
|
||||
Center = 4,
|
||||
CenterRight = 5,
|
||||
UpLeft = 6,
|
||||
UpCenter = 7,
|
||||
UpRight = 8
|
||||
}
|
||||
|
||||
public enum GazeChannelUsage
|
||||
{
|
||||
EyeLook = 0,
|
||||
Corrective = 1
|
||||
}
|
||||
|
||||
public enum GazeCorrectiveBlendMode
|
||||
{
|
||||
AdditiveFromCenter = 0,
|
||||
Override = 1
|
||||
}
|
||||
|
||||
public enum GazeCutResponse
|
||||
{
|
||||
Snap = 0,
|
||||
Smooth = 1
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class GazeBlendShapeChannel
|
||||
{
|
||||
[SerializeField]
|
||||
private string blendShapeName;
|
||||
|
||||
[SerializeField]
|
||||
private GazeChannelUsage usage;
|
||||
|
||||
[SerializeField]
|
||||
[Tooltip("Corrective channels normally add their direction-dependent delta from the Center sample to the live facial value.")]
|
||||
private GazeCorrectiveBlendMode correctiveBlendMode;
|
||||
|
||||
public GazeBlendShapeChannel(
|
||||
string blendShapeName,
|
||||
GazeChannelUsage usage = GazeChannelUsage.EyeLook,
|
||||
GazeCorrectiveBlendMode correctiveBlendMode = GazeCorrectiveBlendMode.AdditiveFromCenter)
|
||||
{
|
||||
this.blendShapeName = blendShapeName;
|
||||
this.usage = usage;
|
||||
this.correctiveBlendMode = correctiveBlendMode;
|
||||
}
|
||||
|
||||
public string BlendShapeName
|
||||
{
|
||||
get => blendShapeName;
|
||||
set => blendShapeName = value;
|
||||
}
|
||||
|
||||
public GazeChannelUsage Usage
|
||||
{
|
||||
get => usage;
|
||||
set => usage = value;
|
||||
}
|
||||
|
||||
public GazeCorrectiveBlendMode CorrectiveBlendMode
|
||||
{
|
||||
get => correctiveBlendMode;
|
||||
set => correctiveBlendMode = value;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class GazeCalibrationSample
|
||||
{
|
||||
[SerializeField]
|
||||
private GazeCalibrationPoint point;
|
||||
|
||||
[SerializeField]
|
||||
private bool captured;
|
||||
|
||||
[SerializeField]
|
||||
private float[] weights = Array.Empty<float>();
|
||||
|
||||
public GazeCalibrationSample(GazeCalibrationPoint point)
|
||||
{
|
||||
this.point = point;
|
||||
}
|
||||
|
||||
public GazeCalibrationPoint Point => point;
|
||||
|
||||
public bool Captured => captured;
|
||||
|
||||
public IReadOnlyList<float> Weights => weights;
|
||||
|
||||
internal float GetWeight(int index)
|
||||
{
|
||||
return weights != null && index >= 0 && index < weights.Length ? weights[index] : 0f;
|
||||
}
|
||||
|
||||
internal void SetWeights(IReadOnlyList<float> values, int channelCount)
|
||||
{
|
||||
var count = Mathf.Max(0, channelCount);
|
||||
if (weights == null || weights.Length != count)
|
||||
weights = new float[count];
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
weights[i] = values != null && i < values.Count ? values[i] : 0f;
|
||||
|
||||
captured = true;
|
||||
}
|
||||
|
||||
internal void Resize(int channelCount)
|
||||
{
|
||||
var count = Mathf.Max(0, channelCount);
|
||||
if (weights != null && weights.Length == count)
|
||||
return;
|
||||
|
||||
var resized = new float[count];
|
||||
if (weights != null)
|
||||
Array.Copy(weights, resized, Mathf.Min(weights.Length, resized.Length));
|
||||
weights = resized;
|
||||
captured = false;
|
||||
}
|
||||
|
||||
internal void Invalidate()
|
||||
{
|
||||
captured = false;
|
||||
}
|
||||
|
||||
internal void SetPoint(GazeCalibrationPoint value)
|
||||
{
|
||||
point = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command passed from Timeline (or a future real-time source) to the final
|
||||
/// LateUpdate blendshape writer.
|
||||
/// </summary>
|
||||
public struct GazeTimelineState
|
||||
{
|
||||
public Vector3 TargetPosition;
|
||||
public bool HasTargetPosition;
|
||||
public Vector3 TargetDirection;
|
||||
public bool HasTargetDirection;
|
||||
public Transform Target;
|
||||
public bool UseMainCamera;
|
||||
public float Influence;
|
||||
public bool EnableCorrectives;
|
||||
public bool OverrideCutResponse;
|
||||
public GazeCutResponse CutResponse;
|
||||
public float CutTransitionDuration;
|
||||
public bool ResetSmoothing;
|
||||
public bool Deterministic;
|
||||
}
|
||||
|
||||
public readonly struct GazeDirectionResult
|
||||
{
|
||||
public GazeDirectionResult(
|
||||
Vector3 worldDirection,
|
||||
Vector3 localDirection,
|
||||
float rawYaw,
|
||||
float rawPitch,
|
||||
float clampedYaw,
|
||||
float clampedPitch,
|
||||
float normalizedYaw,
|
||||
float normalizedPitch)
|
||||
{
|
||||
WorldDirection = worldDirection;
|
||||
LocalDirection = localDirection;
|
||||
RawYaw = rawYaw;
|
||||
RawPitch = rawPitch;
|
||||
ClampedYaw = clampedYaw;
|
||||
ClampedPitch = clampedPitch;
|
||||
NormalizedYaw = normalizedYaw;
|
||||
NormalizedPitch = normalizedPitch;
|
||||
}
|
||||
|
||||
public Vector3 WorldDirection { get; }
|
||||
public Vector3 LocalDirection { get; }
|
||||
public float RawYaw { get; }
|
||||
public float RawPitch { get; }
|
||||
public float ClampedYaw { get; }
|
||||
public float ClampedPitch { get; }
|
||||
public float NormalizedYaw { get; }
|
||||
public float NormalizedPitch { get; }
|
||||
|
||||
public bool WasClamped => !Mathf.Approximately(RawYaw, ClampedYaw)
|
||||
|| !Mathf.Approximately(RawPitch, ClampedPitch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec857c34fe2ba9b45ae6f0fd6864a82f
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Streamingle.Gaze.Runtime",
|
||||
"rootNamespace": "Streamingle.Gaze",
|
||||
"references": [
|
||||
"Unity.Timeline"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b0cc41e29441174ab8738a308dd8d9c
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c7b58b99b68445c4a96a4ad22d8a2bcb
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,23 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Playables;
|
||||
|
||||
namespace Streamingle.Gaze
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime data copied from a <see cref="BlendshapeGazeClip"/>.
|
||||
/// </summary>
|
||||
public sealed class BlendshapeGazeBehaviour : PlayableBehaviour
|
||||
{
|
||||
public Transform Target { get; internal set; }
|
||||
|
||||
public float Strength { get; internal set; }
|
||||
|
||||
public bool EnableCorrectives { get; internal set; }
|
||||
|
||||
public bool OverrideCutResponse { get; internal set; }
|
||||
|
||||
public GazeCutResponse CutResponse { get; internal set; }
|
||||
|
||||
public float CutTransitionDuration { get; internal set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 07c9f3da83d6a724b91c78d798091a03
|
||||
@@ -0,0 +1,104 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Playables;
|
||||
using UnityEngine.Timeline;
|
||||
|
||||
namespace Streamingle.Gaze
|
||||
{
|
||||
/// <summary>
|
||||
/// Describes a camera/target gaze request on a Timeline track.
|
||||
/// The track mixer combines requests and submits the result to a
|
||||
/// <see cref="BlendshapeGazeDriver"/>; clips never write blendshapes directly.
|
||||
/// </summary>
|
||||
public sealed class BlendshapeGazeClip : PlayableAsset, ITimelineClipAsset
|
||||
{
|
||||
[SerializeField]
|
||||
[Tooltip("Optional world-space target. When empty, the Driver Target Camera is used before Camera.main.")]
|
||||
private ExposedReference<Transform> target;
|
||||
|
||||
[SerializeField, Range(0f, 1f)]
|
||||
[Tooltip("Maximum gaze influence contributed by this clip.")]
|
||||
private float strength = 1f;
|
||||
|
||||
[SerializeField]
|
||||
[Tooltip("Allow optional eyelid/corrective channels from the calibration profile.")]
|
||||
private bool enableCorrectives;
|
||||
|
||||
[SerializeField]
|
||||
[Tooltip("Override the bound driver's camera-cut response while this clip is dominant.")]
|
||||
private bool overrideCutResponse;
|
||||
|
||||
[SerializeField]
|
||||
private GazeCutResponse cutResponse;
|
||||
|
||||
[SerializeField, Min(0f)]
|
||||
[Tooltip("Transition duration used by cut-response modes that smooth between cameras.")]
|
||||
private float cutTransitionDuration = 0.1f;
|
||||
|
||||
public ExposedReference<Transform> Target
|
||||
{
|
||||
get => target;
|
||||
set => target = value;
|
||||
}
|
||||
|
||||
public float Strength
|
||||
{
|
||||
get => strength;
|
||||
set => strength = SanitizeStrength(value);
|
||||
}
|
||||
|
||||
public bool EnableCorrectives
|
||||
{
|
||||
get => enableCorrectives;
|
||||
set => enableCorrectives = value;
|
||||
}
|
||||
|
||||
public bool OverrideCutResponse
|
||||
{
|
||||
get => overrideCutResponse;
|
||||
set => overrideCutResponse = value;
|
||||
}
|
||||
|
||||
public GazeCutResponse CutResponse
|
||||
{
|
||||
get => cutResponse;
|
||||
set => cutResponse = value;
|
||||
}
|
||||
|
||||
public float CutTransitionDuration
|
||||
{
|
||||
get => cutTransitionDuration;
|
||||
set => cutTransitionDuration = SanitizeDuration(value);
|
||||
}
|
||||
|
||||
public ClipCaps clipCaps => ClipCaps.Blending;
|
||||
|
||||
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
|
||||
{
|
||||
var playable = ScriptPlayable<BlendshapeGazeBehaviour>.Create(graph);
|
||||
var behaviour = playable.GetBehaviour();
|
||||
|
||||
behaviour.Target = target.Resolve(graph.GetResolver());
|
||||
behaviour.Strength = SanitizeStrength(strength);
|
||||
behaviour.EnableCorrectives = enableCorrectives;
|
||||
behaviour.OverrideCutResponse = overrideCutResponse;
|
||||
behaviour.CutResponse = cutResponse;
|
||||
behaviour.CutTransitionDuration = SanitizeDuration(cutTransitionDuration);
|
||||
|
||||
return playable;
|
||||
}
|
||||
|
||||
private static float SanitizeStrength(float value)
|
||||
{
|
||||
return float.IsNaN(value) || float.IsInfinity(value)
|
||||
? 0f
|
||||
: Mathf.Clamp01(value);
|
||||
}
|
||||
|
||||
private static float SanitizeDuration(float value)
|
||||
{
|
||||
return float.IsNaN(value) || float.IsInfinity(value)
|
||||
? 0f
|
||||
: Mathf.Max(0f, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3df39f801d7538f46933258e013ceb96
|
||||
@@ -0,0 +1,286 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Playables;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace Streamingle.Gaze
|
||||
{
|
||||
/// <summary>
|
||||
/// Blends Timeline clip requests into one command for the bound driver.
|
||||
/// </summary>
|
||||
public sealed class BlendshapeGazeMixerBehaviour : PlayableBehaviour
|
||||
{
|
||||
private const float MinimumWeight = 0.0001f;
|
||||
|
||||
private static int nextSourceId;
|
||||
#if UNITY_EDITOR
|
||||
private static readonly HashSet<BlendshapeGazeDriver> PendingEditorEvaluations =
|
||||
new HashSet<BlendshapeGazeDriver>();
|
||||
private static bool editorEvaluationScheduled;
|
||||
#endif
|
||||
|
||||
private readonly int sourceId = Interlocked.Increment(ref nextSourceId);
|
||||
|
||||
private BlendshapeGazeDriver boundDriver;
|
||||
private bool hasSubmittedState;
|
||||
private bool hasProcessedFrame;
|
||||
|
||||
public override void PrepareFrame(Playable playable, FrameData info)
|
||||
{
|
||||
if (hasSubmittedState && boundDriver != null)
|
||||
boundDriver.PrepareForTimelineEvaluation();
|
||||
}
|
||||
|
||||
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
|
||||
{
|
||||
var driver = playerData as BlendshapeGazeDriver;
|
||||
if (driver == null)
|
||||
{
|
||||
ClearBoundDriver(ShouldEvaluateImmediately());
|
||||
return;
|
||||
}
|
||||
|
||||
if (boundDriver != driver)
|
||||
{
|
||||
ClearBoundDriver(ShouldEvaluateImmediately());
|
||||
boundDriver = driver;
|
||||
}
|
||||
|
||||
var outputWeight = Mathf.Max(0f, info.effectiveWeight);
|
||||
var totalWeight = 0f;
|
||||
var gazeOriginPosition = driver.GetGazeOriginPosition();
|
||||
var weightedTargetDirection = Vector3.zero;
|
||||
var hasCommonTarget = false;
|
||||
var hasDifferentTargets = false;
|
||||
var hasUnresolvedTarget = false;
|
||||
Transform commonTarget = null;
|
||||
var dominantWeight = float.NegativeInfinity;
|
||||
BlendshapeGazeBehaviour dominantBehaviour = null;
|
||||
Camera resolvedCamera = null;
|
||||
|
||||
var inputCount = playable.GetInputCount();
|
||||
for (var i = 0; i < inputCount; i++)
|
||||
{
|
||||
var inputPlayable = (ScriptPlayable<BlendshapeGazeBehaviour>)playable.GetInput(i);
|
||||
if (!inputPlayable.IsValid())
|
||||
continue;
|
||||
|
||||
var behaviour = inputPlayable.GetBehaviour();
|
||||
var inputWeight = Mathf.Max(0f, playable.GetInputWeight(i));
|
||||
var contribution = inputWeight * outputWeight * Mathf.Clamp01(behaviour.Strength);
|
||||
if (contribution <= MinimumWeight)
|
||||
continue;
|
||||
|
||||
totalWeight += contribution;
|
||||
|
||||
var requestedTarget = behaviour.Target;
|
||||
if (!hasCommonTarget)
|
||||
{
|
||||
commonTarget = requestedTarget;
|
||||
hasCommonTarget = true;
|
||||
}
|
||||
else if (commonTarget != requestedTarget)
|
||||
{
|
||||
hasDifferentTargets = true;
|
||||
}
|
||||
|
||||
var resolvedTarget = requestedTarget;
|
||||
if (resolvedTarget == null)
|
||||
{
|
||||
if (resolvedCamera == null)
|
||||
resolvedCamera = driver.TargetCamera != null
|
||||
? driver.TargetCamera
|
||||
: Camera.main;
|
||||
|
||||
if (resolvedCamera != null)
|
||||
resolvedTarget = resolvedCamera.transform;
|
||||
}
|
||||
|
||||
if (resolvedTarget != null)
|
||||
{
|
||||
var targetOffset = resolvedTarget.position - gazeOriginPosition;
|
||||
if (targetOffset.sqrMagnitude > MinimumWeight * MinimumWeight)
|
||||
{
|
||||
weightedTargetDirection += targetOffset.normalized * contribution;
|
||||
}
|
||||
else
|
||||
{
|
||||
hasUnresolvedTarget = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
hasUnresolvedTarget = true;
|
||||
}
|
||||
|
||||
if (contribution > dominantWeight)
|
||||
{
|
||||
dominantWeight = contribution;
|
||||
dominantBehaviour = behaviour;
|
||||
}
|
||||
}
|
||||
|
||||
var canDeferTargetResolution = hasCommonTarget && !hasDifferentTargets;
|
||||
|
||||
// A shared Transform (including a null target meaning Main Camera) is
|
||||
// deliberately deferred to the driver. This lets LateUpdate observe the
|
||||
// final Cinemachine camera pose. Distinct overlapping targets must be
|
||||
// collapsed to a fixed blended direction during Timeline evaluation.
|
||||
// Directions, rather than positions, prevent a distant target from
|
||||
// outweighing a nearby target with the same Timeline contribution.
|
||||
var cannotBlendTargets = !canDeferTargetResolution
|
||||
&& (weightedTargetDirection.sqrMagnitude <= MinimumWeight * MinimumWeight
|
||||
|| hasUnresolvedTarget);
|
||||
|
||||
if (totalWeight <= MinimumWeight || dominantBehaviour == null || cannotBlendTargets)
|
||||
{
|
||||
ClearSubmittedState(ShouldEvaluateImmediately());
|
||||
hasProcessedFrame = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var resetSmoothing = !hasProcessedFrame || info.seekOccurred || info.timeLooped;
|
||||
var deterministic = !Application.isPlaying
|
||||
|| info.evaluationType == FrameData.EvaluationType.Evaluate
|
||||
|| info.seekOccurred
|
||||
|| info.timeLooped;
|
||||
|
||||
var state = new GazeTimelineState
|
||||
{
|
||||
TargetDirection = canDeferTargetResolution
|
||||
? Vector3.zero
|
||||
: weightedTargetDirection.normalized,
|
||||
HasTargetDirection = !canDeferTargetResolution,
|
||||
Target = canDeferTargetResolution ? commonTarget : null,
|
||||
UseMainCamera = canDeferTargetResolution && commonTarget == null,
|
||||
Influence = Mathf.Clamp01(totalWeight),
|
||||
EnableCorrectives = dominantBehaviour.EnableCorrectives,
|
||||
OverrideCutResponse = dominantBehaviour.OverrideCutResponse,
|
||||
CutResponse = dominantBehaviour.CutResponse,
|
||||
CutTransitionDuration = dominantBehaviour.CutTransitionDuration,
|
||||
ResetSmoothing = resetSmoothing,
|
||||
Deterministic = deterministic
|
||||
};
|
||||
|
||||
driver.SetTimelineState(sourceId, state);
|
||||
hasSubmittedState = true;
|
||||
hasProcessedFrame = true;
|
||||
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
// Evaluate after the whole Timeline graph has applied its animation
|
||||
// outputs. This pass sees the animated GazeOrigin, final camera pose
|
||||
// and facial blendshape baseline from this frame.
|
||||
ScheduleEditorFinalEvaluation(driver);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnGraphStop(Playable playable)
|
||||
{
|
||||
ClearBoundDriver(ShouldEvaluateImmediately());
|
||||
hasProcessedFrame = false;
|
||||
}
|
||||
|
||||
public override void OnPlayableDestroy(Playable playable)
|
||||
{
|
||||
ClearBoundDriver(ShouldEvaluateImmediately());
|
||||
hasProcessedFrame = false;
|
||||
}
|
||||
|
||||
private void ClearSubmittedState(bool evaluateImmediately)
|
||||
{
|
||||
if (!hasSubmittedState)
|
||||
return;
|
||||
|
||||
if (boundDriver != null)
|
||||
{
|
||||
boundDriver.ClearTimelineState(sourceId);
|
||||
|
||||
if (evaluateImmediately)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
ScheduleEditorFinalEvaluation(boundDriver);
|
||||
#else
|
||||
boundDriver.EvaluateNow(true);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
hasSubmittedState = false;
|
||||
}
|
||||
|
||||
private void ClearBoundDriver(bool evaluateImmediately)
|
||||
{
|
||||
ClearSubmittedState(evaluateImmediately);
|
||||
boundDriver = null;
|
||||
}
|
||||
|
||||
private static bool ShouldEvaluateImmediately()
|
||||
{
|
||||
return !Application.isPlaying;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
internal static void ScheduleEditorFinalEvaluation(BlendshapeGazeDriver driver)
|
||||
{
|
||||
if (driver == null)
|
||||
return;
|
||||
|
||||
PendingEditorEvaluations.Add(driver);
|
||||
if (editorEvaluationScheduled)
|
||||
return;
|
||||
|
||||
editorEvaluationScheduled = true;
|
||||
EditorApplication.delayCall += FlushPendingEditorEvaluations;
|
||||
// Manual/paused Timeline evaluations do not guarantee an ExecuteAlways
|
||||
// LateUpdate. Request one so the driver gets a post-animation final pass.
|
||||
EditorApplication.QueuePlayerLoopUpdate();
|
||||
}
|
||||
|
||||
internal static void FlushPendingEditorEvaluations()
|
||||
{
|
||||
EditorApplication.delayCall -= FlushPendingEditorEvaluations;
|
||||
editorEvaluationScheduled = false;
|
||||
|
||||
if (PendingEditorEvaluations.Count == 0)
|
||||
return;
|
||||
|
||||
var drivers = new BlendshapeGazeDriver[PendingEditorEvaluations.Count];
|
||||
PendingEditorEvaluations.CopyTo(drivers);
|
||||
PendingEditorEvaluations.Clear();
|
||||
|
||||
if (EditorApplication.isPlayingOrWillChangePlaymode)
|
||||
return;
|
||||
|
||||
var evaluatedAny = false;
|
||||
for (var index = 0; index < drivers.Length; index++)
|
||||
{
|
||||
if (drivers[index] != null && drivers[index].isActiveAndEnabled)
|
||||
{
|
||||
drivers[index].EvaluateNow(true);
|
||||
evaluatedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluatedAny)
|
||||
SceneView.RepaintAll();
|
||||
}
|
||||
|
||||
internal static void CancelEditorFinalEvaluation(BlendshapeGazeDriver driver)
|
||||
{
|
||||
if (object.ReferenceEquals(driver, null))
|
||||
return;
|
||||
|
||||
PendingEditorEvaluations.Remove(driver);
|
||||
if (PendingEditorEvaluations.Count > 0 || !editorEvaluationScheduled)
|
||||
return;
|
||||
|
||||
EditorApplication.delayCall -= FlushPendingEditorEvaluations;
|
||||
editorEvaluationScheduled = false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 84259821b954cf84f853e4a88b17e80d
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Playables;
|
||||
using UnityEngine.Timeline;
|
||||
|
||||
namespace Streamingle.Gaze
|
||||
{
|
||||
[TrackColor(0.23f, 0.74f, 0.92f)]
|
||||
[TrackBindingType(typeof(BlendshapeGazeDriver))]
|
||||
[TrackClipType(typeof(BlendshapeGazeClip))]
|
||||
public sealed class BlendshapeGazeTrack : TrackAsset
|
||||
{
|
||||
public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
|
||||
{
|
||||
return ScriptPlayable<BlendshapeGazeMixerBehaviour>.Create(graph, inputCount);
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public override void GatherProperties(PlayableDirector director, IPropertyCollector collector)
|
||||
{
|
||||
var gazeDriver = director != null
|
||||
? director.GetGenericBinding(this) as BlendshapeGazeDriver
|
||||
: null;
|
||||
|
||||
var targetRenderer = gazeDriver != null ? gazeDriver.TargetRenderer : null;
|
||||
var profile = gazeDriver != null ? gazeDriver.Profile : null;
|
||||
var channels = profile != null ? profile.Channels : null;
|
||||
var mesh = targetRenderer != null ? targetRenderer.sharedMesh : null;
|
||||
if (targetRenderer != null && mesh != null && channels != null)
|
||||
{
|
||||
var channelNames = new HashSet<string>();
|
||||
var previewBindings = new AnimationClip
|
||||
{
|
||||
name = "Blendshape Gaze Preview Bindings",
|
||||
hideFlags = HideFlags.HideAndDontSave
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < channels.Count; i++)
|
||||
{
|
||||
var channel = channels[i];
|
||||
if (object.ReferenceEquals(channel, null))
|
||||
continue;
|
||||
|
||||
var blendShapeIndex = profile.FindBlendShapeIndex(mesh, i);
|
||||
if (blendShapeIndex < 0)
|
||||
continue;
|
||||
|
||||
// AddFromName expects a SerializedProperty path, while
|
||||
// blendShape.* is a synthetic Animator curve binding.
|
||||
// Register it through a clip so Timeline's AnimationMode
|
||||
// resolves and restores the real renderer property.
|
||||
var channelName = mesh.GetBlendShapeName(blendShapeIndex);
|
||||
if (string.IsNullOrWhiteSpace(channelName) || !channelNames.Add(channelName))
|
||||
continue;
|
||||
|
||||
previewBindings.SetCurve(
|
||||
string.Empty,
|
||||
typeof(SkinnedMeshRenderer),
|
||||
"blendShape." + channelName,
|
||||
AnimationCurve.Constant(0f, 1f, 0f));
|
||||
}
|
||||
|
||||
if (channelNames.Count > 0)
|
||||
collector.AddFromClip(targetRenderer.gameObject, previewBindings);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(previewBindings);
|
||||
}
|
||||
}
|
||||
|
||||
base.GatherProperties(director, collector);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6c2beb3d591978e49bbc25526fa7fc51
|
||||
Reference in New Issue
Block a user