750 lines
26 KiB
C#
750 lines
26 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|