feat: initialize Streamingle Utilities package

This commit is contained in:
KINDNICK
2026-07-23 22:43:07 +09:00
commit e9bbf8481a
107 changed files with 9746 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
using UnityEngine;
namespace Streamingle.Utils
{
/// <summary>
/// 타겟 오브젝트를 부드럽게 따라가는 컴포넌트
/// </summary>
public class SmoothFollow : MonoBehaviour
{
[Header("Target")]
[Tooltip("따라갈 대상 Transform")]
public Transform target;
[Header("Follow Settings")]
[Tooltip("위치 따라가기 활성화")]
public bool followPosition = true;
[Tooltip("회전 따라가기 활성화")]
public bool followRotation = false;
[Header("Smoothing")]
[Tooltip("위치 보간 속도 (낮을수록 더 부드럽게)")]
[Range(0.01f, 20f)]
public float positionSmoothSpeed = 5f;
[Tooltip("회전 보간 속도 (낮을수록 더 부드럽게)")]
[Range(0.01f, 20f)]
public float rotationSmoothSpeed = 5f;
[Header("Offset")]
[Tooltip("타겟으로부터의 위치 오프셋 (로컬 기준)")]
public Vector3 positionOffset = Vector3.zero;
[Tooltip("타겟으로부터의 회전 오프셋")]
public Vector3 rotationOffset = Vector3.zero;
[Header("Constraints")]
[Tooltip("X축 위치 고정")]
public bool freezePositionX = false;
[Tooltip("Y축 위치 고정")]
public bool freezePositionY = false;
[Tooltip("Z축 위치 고정")]
public bool freezePositionZ = false;
private Vector3 _initialPosition;
private Quaternion _initialRotation;
private Vector3 _velocity = Vector3.zero;
private void Awake()
{
_initialPosition = transform.position;
_initialRotation = transform.rotation;
}
private void LateUpdate()
{
if (target == null) return;
if (followPosition)
{
UpdatePosition();
}
if (followRotation)
{
UpdateRotation();
}
}
private void UpdatePosition()
{
// 타겟 위치 + 로컬 오프셋 계산
Vector3 targetPosition = target.TransformPoint(positionOffset);
// 부드러운 보간
Vector3 smoothedPosition = Vector3.Lerp(
transform.position,
targetPosition,
positionSmoothSpeed * Time.deltaTime
);
// 축 고정 적용
if (freezePositionX) smoothedPosition.x = _initialPosition.x;
if (freezePositionY) smoothedPosition.y = _initialPosition.y;
if (freezePositionZ) smoothedPosition.z = _initialPosition.z;
transform.position = smoothedPosition;
}
private void UpdateRotation()
{
Quaternion targetRotation = target.rotation * Quaternion.Euler(rotationOffset);
transform.rotation = Quaternion.Slerp(
transform.rotation,
targetRotation,
rotationSmoothSpeed * Time.deltaTime
);
}
/// <summary>
/// 타겟을 런타임에 변경
/// </summary>
public void SetTarget(Transform newTarget)
{
target = newTarget;
}
/// <summary>
/// 즉시 타겟 위치로 이동 (보간 없이)
/// </summary>
public void SnapToTarget()
{
if (target == null) return;
if (followPosition)
{
Vector3 targetPosition = target.TransformPoint(positionOffset);
if (freezePositionX) targetPosition.x = _initialPosition.x;
if (freezePositionY) targetPosition.y = _initialPosition.y;
if (freezePositionZ) targetPosition.z = _initialPosition.z;
transform.position = targetPosition;
}
if (followRotation)
{
transform.rotation = target.rotation * Quaternion.Euler(rotationOffset);
}
}
/// <summary>
/// 초기 위치로 복귀
/// </summary>
public void ResetToInitial()
{
transform.position = _initialPosition;
transform.rotation = _initialRotation;
}
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
if (target == null) return;
Gizmos.color = Color.cyan;
Vector3 targetPos = target.TransformPoint(positionOffset);
Gizmos.DrawLine(transform.position, targetPos);
Gizmos.DrawWireSphere(targetPos, 0.1f);
}
#endif
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 53b895a2741ab0c4db91ec0dcb7c1a06
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 31342348d6d2460382d40f327f52bedc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+50
View File
@@ -0,0 +1,50 @@
using UnityEngine;
namespace Streamingle.Utils.RoughConstraints
{
[ExecuteAlways]
[AddComponentMenu("Streamingle Utilities/Constraints/Rough Aim Constraint")]
public class RoughAimConstraint : RoughConstraintBase
{
[Header("Aim")]
public Vector3 aimAxis = Vector3.forward;
public Vector3 upAxis = Vector3.up;
public Vector3 worldUpVector = Vector3.up;
public Vector3 rotationOffset;
protected override void ExecuteConstraint(float followT)
{
if (!TryGetWeightedPosition(out var targetPosition))
{
return;
}
var direction = targetPosition - transform.position;
if (direction.sqrMagnitude < 0.000001f)
{
return;
}
var targetRotation = LookRotationForAxis(direction, worldUpVector, aimAxis, upAxis);
targetRotation *= Quaternion.Euler(rotationOffset);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, followT * weight);
}
protected override void CaptureOffsetsFromCurrentPose()
{
if (!TryGetWeightedPosition(out var targetPosition))
{
return;
}
var direction = targetPosition - transform.position;
if (direction.sqrMagnitude < 0.000001f)
{
return;
}
var baseRotation = LookRotationForAxis(direction, worldUpVector, aimAxis, upAxis);
rotationOffset = (Quaternion.Inverse(baseRotation) * transform.rotation).eulerAngles;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 70b32d75aafa49d781fe9cd7389d78b5
+356
View File
@@ -0,0 +1,356 @@
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace Streamingle.Utils.RoughConstraints
{
public enum RoughConstraintUpdateMode
{
Update,
LateUpdate,
FixedUpdate,
Manual
}
[ExecuteAlways]
public abstract class RoughConstraintBase : MonoBehaviour
{
[Header("Constraint")]
public bool constraintActive = true;
public bool locked = true;
[Range(0f, 1f)]
public float weight = 1f;
public List<RoughConstraintSource> sources = new List<RoughConstraintSource>();
[Header("Rough Follow")]
[Min(0f)]
public float damping = 12f;
public RoughConstraintUpdateMode updateMode = RoughConstraintUpdateMode.LateUpdate;
public bool useUnscaledTime;
public bool snapOnEnable = true;
#if UNITY_EDITOR
private bool editorUpdateRegistered;
#endif
protected float CurrentDeltaTime
{
get
{
if (!Application.isPlaying)
{
return 1f / 60f;
}
if (updateMode == RoughConstraintUpdateMode.FixedUpdate)
{
return useUnscaledTime ? Time.fixedUnscaledDeltaTime : Time.fixedDeltaTime;
}
return useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime;
}
}
protected float FollowT
{
get
{
if (damping <= 0f)
{
return 1f;
}
return 1f - Mathf.Exp(-damping * Mathf.Max(0f, CurrentDeltaTime));
}
}
protected virtual void OnEnable()
{
#if UNITY_EDITOR
RegisterEditorUpdate();
#endif
if (!snapOnEnable)
{
return;
}
SnapToSources();
}
protected virtual void OnDisable()
{
#if UNITY_EDITOR
UnregisterEditorUpdate();
#endif
}
private void Update()
{
if (!Application.isPlaying)
{
if (updateMode != RoughConstraintUpdateMode.Manual)
{
Evaluate();
}
return;
}
if (updateMode == RoughConstraintUpdateMode.Update)
{
Evaluate();
}
}
private void LateUpdate()
{
if (!Application.isPlaying)
{
return;
}
if (updateMode == RoughConstraintUpdateMode.LateUpdate)
{
Evaluate();
}
}
private void FixedUpdate()
{
if (!Application.isPlaying)
{
return;
}
if (updateMode == RoughConstraintUpdateMode.FixedUpdate)
{
Evaluate();
}
}
#if UNITY_EDITOR
private void RegisterEditorUpdate()
{
if (editorUpdateRegistered)
{
return;
}
EditorApplication.update += EditorUpdate;
editorUpdateRegistered = true;
}
private void UnregisterEditorUpdate()
{
if (!editorUpdateRegistered)
{
return;
}
EditorApplication.update -= EditorUpdate;
editorUpdateRegistered = false;
}
private void EditorUpdate()
{
if (this == null)
{
UnregisterEditorUpdate();
return;
}
if (Application.isPlaying || updateMode == RoughConstraintUpdateMode.Manual)
{
return;
}
Evaluate();
}
#endif
public void Evaluate()
{
if (!CanEvaluate())
{
return;
}
ExecuteConstraint(FollowT);
}
public void SnapToSources()
{
if (!CanEvaluate())
{
return;
}
ExecuteConstraint(1f);
}
public void SetLocked(bool value)
{
if (value && !locked)
{
CaptureOffsetsFromCurrentPose();
}
locked = value;
}
public void ActivateConstraint()
{
constraintActive = true;
locked = true;
SnapToSources();
}
protected abstract void ExecuteConstraint(float followT);
protected virtual void CaptureOffsetsFromCurrentPose()
{
}
private bool CanEvaluate()
{
return constraintActive && locked && weight > 0f;
}
protected bool TryGetWeightedPosition(out Vector3 position)
{
position = Vector3.zero;
var totalWeight = 0f;
for (var i = 0; i < sources.Count; i++)
{
var source = sources[i];
if (source.sourceTransform == null || source.weight <= 0f)
{
continue;
}
position += source.sourceTransform.position * source.weight;
totalWeight += source.weight;
}
if (totalWeight <= 0f)
{
return false;
}
position /= totalWeight;
return true;
}
protected bool TryGetWeightedRotation(out Quaternion rotation)
{
rotation = Quaternion.identity;
var hasRotation = false;
var accumulatedWeight = 0f;
for (var i = 0; i < sources.Count; i++)
{
var source = sources[i];
if (source.sourceTransform == null || source.weight <= 0f)
{
continue;
}
var sourceRotation = source.sourceTransform.rotation;
if (!hasRotation)
{
rotation = sourceRotation;
accumulatedWeight = source.weight;
hasRotation = true;
continue;
}
var lerpWeight = source.weight / (accumulatedWeight + source.weight);
rotation = Quaternion.Slerp(rotation, sourceRotation, lerpWeight);
accumulatedWeight += source.weight;
}
return hasRotation;
}
protected bool TryGetWeightedScale(out Vector3 scale)
{
scale = Vector3.zero;
var totalWeight = 0f;
for (var i = 0; i < sources.Count; i++)
{
var source = sources[i];
if (source.sourceTransform == null || source.weight <= 0f)
{
continue;
}
scale += source.sourceTransform.localScale * source.weight;
totalWeight += source.weight;
}
if (totalWeight <= 0f)
{
return false;
}
scale /= totalWeight;
return true;
}
protected static Vector3 ApplyAxisMask(Vector3 current, Vector3 target, Vector3Bool constrainedAxis)
{
if (!constrainedAxis.x) target.x = current.x;
if (!constrainedAxis.y) target.y = current.y;
if (!constrainedAxis.z) target.z = current.z;
return target;
}
protected static Vector3 ApplyEulerAxisMask(Quaternion current, Quaternion target, Vector3Bool constrainedAxis)
{
var currentEuler = current.eulerAngles;
var targetEuler = target.eulerAngles;
if (!constrainedAxis.x) targetEuler.x = currentEuler.x;
if (!constrainedAxis.y) targetEuler.y = currentEuler.y;
if (!constrainedAxis.z) targetEuler.z = currentEuler.z;
return targetEuler;
}
protected static Quaternion LookRotationForAxis(Vector3 direction, Vector3 worldUp, Vector3 localForward, Vector3 localUp)
{
var safeDirection = SafeNormal(direction, Vector3.forward);
var safeWorldUp = SafeUp(safeDirection, worldUp);
var safeLocalForward = SafeNormal(localForward, Vector3.forward);
var safeLocalUp = SafeUp(safeLocalForward, localUp);
var targetWorldRotation = Quaternion.LookRotation(safeDirection, safeWorldUp);
var localAxisRotation = Quaternion.LookRotation(safeLocalForward, safeLocalUp);
return targetWorldRotation * Quaternion.Inverse(localAxisRotation);
}
private static Vector3 SafeNormal(Vector3 value, Vector3 fallback)
{
return value.sqrMagnitude > 0.000001f ? value.normalized : fallback;
}
private static Vector3 SafeUp(Vector3 forward, Vector3 up)
{
var safeUp = SafeNormal(up, Vector3.up);
if (Mathf.Abs(Vector3.Dot(forward, safeUp)) < 0.999f)
{
return safeUp;
}
return Mathf.Abs(Vector3.Dot(forward, Vector3.up)) < 0.999f ? Vector3.up : Vector3.right;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 113d932d171c415fa3b62ab75bdf3de7
@@ -0,0 +1,14 @@
using System;
using UnityEngine;
namespace Streamingle.Utils.RoughConstraints
{
[Serializable]
public struct RoughConstraintSource
{
public Transform sourceTransform;
[Range(0f, 1f)]
public float weight;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7192f25e37a54bc385855e11d7f3abc3
@@ -0,0 +1,52 @@
using UnityEngine;
namespace Streamingle.Utils.RoughConstraints
{
[ExecuteAlways]
[AddComponentMenu("Streamingle Utilities/Constraints/Rough Look At Constraint")]
public class RoughLookAtConstraint : RoughConstraintBase
{
[Header("Look At")]
public Vector3 forwardAxis = Vector3.forward;
public Vector3 worldUpVector = Vector3.up;
public Vector3 targetPositionOffset;
public Vector3 rotationOffset;
protected override void ExecuteConstraint(float followT)
{
if (!TryGetWeightedPosition(out var targetPosition))
{
return;
}
targetPosition += targetPositionOffset;
var direction = targetPosition - transform.position;
if (direction.sqrMagnitude < 0.000001f)
{
return;
}
var targetRotation = LookRotationForAxis(direction, worldUpVector, forwardAxis, Vector3.up);
targetRotation *= Quaternion.Euler(rotationOffset);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, followT * weight);
}
protected override void CaptureOffsetsFromCurrentPose()
{
if (!TryGetWeightedPosition(out var targetPosition))
{
return;
}
targetPosition += targetPositionOffset;
var direction = targetPosition - transform.position;
if (direction.sqrMagnitude < 0.000001f)
{
return;
}
var baseRotation = LookRotationForAxis(direction, worldUpVector, forwardAxis, Vector3.up);
rotationOffset = (Quaternion.Inverse(baseRotation) * transform.rotation).eulerAngles;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ff851637cd645758f43435b7ee8fb42
@@ -0,0 +1,67 @@
using UnityEngine;
namespace Streamingle.Utils.RoughConstraints
{
[ExecuteAlways]
[AddComponentMenu("Streamingle Utilities/Constraints/Rough Parent Constraint")]
public class RoughParentConstraint : RoughConstraintBase
{
[Header("Position")]
public Vector3Bool constrainedPositionAxis = Vector3Bool.All;
public Vector3 positionOffset;
public bool positionOffsetInSourceSpace = true;
[Header("Rotation")]
public Vector3Bool constrainedRotationAxis = Vector3Bool.All;
public Vector3 rotationOffset;
protected override void ExecuteConstraint(float followT)
{
var blend = followT * weight;
if (TryGetWeightedPosition(out var targetPosition))
{
if (positionOffsetInSourceSpace && sources.Count > 0 && sources[0].sourceTransform != null)
{
targetPosition += sources[0].sourceTransform.TransformVector(positionOffset);
}
else
{
targetPosition += positionOffset;
}
targetPosition = ApplyAxisMask(transform.position, targetPosition, constrainedPositionAxis);
transform.position = Vector3.Lerp(transform.position, targetPosition, blend);
}
if (TryGetWeightedRotation(out var targetRotation))
{
targetRotation *= Quaternion.Euler(rotationOffset);
var maskedEuler = ApplyEulerAxisMask(transform.rotation, targetRotation, constrainedRotationAxis);
targetRotation = Quaternion.Euler(maskedEuler);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, blend);
}
}
protected override void CaptureOffsetsFromCurrentPose()
{
if (TryGetWeightedPosition(out var sourcePosition))
{
var delta = transform.position - sourcePosition;
if (positionOffsetInSourceSpace && sources.Count > 0 && sources[0].sourceTransform != null)
{
positionOffset = sources[0].sourceTransform.InverseTransformVector(delta);
}
else
{
positionOffset = delta;
}
}
if (TryGetWeightedRotation(out var sourceRotation))
{
rotationOffset = (Quaternion.Inverse(sourceRotation) * transform.rotation).eulerAngles;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 015afbd1174749d889a7b3542929c39b
@@ -0,0 +1,52 @@
using UnityEngine;
namespace Streamingle.Utils.RoughConstraints
{
[ExecuteAlways]
[AddComponentMenu("Streamingle Utilities/Constraints/Rough Position Constraint")]
public class RoughPositionConstraint : RoughConstraintBase
{
[Header("Position")]
public Vector3Bool constrainedAxis = Vector3Bool.All;
public Vector3 positionOffset;
public bool offsetInSourceSpace = true;
protected override void ExecuteConstraint(float followT)
{
if (!TryGetWeightedPosition(out var targetPosition))
{
return;
}
if (offsetInSourceSpace && sources.Count > 0 && sources[0].sourceTransform != null)
{
targetPosition += sources[0].sourceTransform.TransformVector(positionOffset);
}
else
{
targetPosition += positionOffset;
}
targetPosition = ApplyAxisMask(transform.position, targetPosition, constrainedAxis);
transform.position = Vector3.Lerp(transform.position, targetPosition, followT * weight);
}
protected override void CaptureOffsetsFromCurrentPose()
{
if (!TryGetWeightedPosition(out var sourcePosition))
{
return;
}
var delta = transform.position - sourcePosition;
if (offsetInSourceSpace && sources.Count > 0 && sources[0].sourceTransform != null)
{
positionOffset = sources[0].sourceTransform.InverseTransformVector(delta);
}
else
{
positionOffset = delta;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b1874b6468e0419f925ee085ce478bfb
@@ -0,0 +1,36 @@
using UnityEngine;
namespace Streamingle.Utils.RoughConstraints
{
[ExecuteAlways]
[AddComponentMenu("Streamingle Utilities/Constraints/Rough Rotation Constraint")]
public class RoughRotationConstraint : RoughConstraintBase
{
[Header("Rotation")]
public Vector3Bool constrainedAxis = Vector3Bool.All;
public Vector3 rotationOffset;
protected override void ExecuteConstraint(float followT)
{
if (!TryGetWeightedRotation(out var targetRotation))
{
return;
}
targetRotation *= Quaternion.Euler(rotationOffset);
var maskedEuler = ApplyEulerAxisMask(transform.rotation, targetRotation, constrainedAxis);
targetRotation = Quaternion.Euler(maskedEuler);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, followT * weight);
}
protected override void CaptureOffsetsFromCurrentPose()
{
if (!TryGetWeightedRotation(out var sourceRotation))
{
return;
}
rotationOffset = (Quaternion.Inverse(sourceRotation) * transform.rotation).eulerAngles;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 23820ffe249c4b7bad25455ca97fc8d3
@@ -0,0 +1,35 @@
using UnityEngine;
namespace Streamingle.Utils.RoughConstraints
{
[ExecuteAlways]
[AddComponentMenu("Streamingle Utilities/Constraints/Rough Scale Constraint")]
public class RoughScaleConstraint : RoughConstraintBase
{
[Header("Scale")]
public Vector3Bool constrainedAxis = Vector3Bool.All;
public Vector3 scaleOffset;
protected override void ExecuteConstraint(float followT)
{
if (!TryGetWeightedScale(out var targetScale))
{
return;
}
targetScale += scaleOffset;
targetScale = ApplyAxisMask(transform.localScale, targetScale, constrainedAxis);
transform.localScale = Vector3.Lerp(transform.localScale, targetScale, followT * weight);
}
protected override void CaptureOffsetsFromCurrentPose()
{
if (!TryGetWeightedScale(out var sourceScale))
{
return;
}
scaleOffset = transform.localScale - sourceScale;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c58bfc39585a4c61bef1c73bf3fbc9a0
+21
View File
@@ -0,0 +1,21 @@
using System;
namespace Streamingle.Utils.RoughConstraints
{
[Serializable]
public struct Vector3Bool
{
public bool x;
public bool y;
public bool z;
public Vector3Bool(bool x, bool y, bool z)
{
this.x = x;
this.y = y;
this.z = z;
}
public static Vector3Bool All => new Vector3Bool(true, true, true);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b3fce5b0afa436abaaba5ea0261354d
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6eb0f0d07a0da5d49b8011fd6f2acbfb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+749
View File
@@ -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
+634
View File
@@ -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
+124
View File
@@ -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);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: db711509b9605ce4eb75578c5cc07215
+203
View File
@@ -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);
}
}
+2
View File
@@ -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:
+8
View File
@@ -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
+104
View File
@@ -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
@@ -0,0 +1,14 @@
{
"name": "Streamingle.Utilities.Runtime",
"rootNamespace": "Streamingle.Utils",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3cb8039709b947446a09709edb79399e
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7e3f7c38c8549684484fa5090615441f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 84ede7a9d3d8a8342a1a4958bbdc19d3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,17 @@
{
"name": "Streamingle.Subtitles.Runtime",
"rootNamespace": "Streamingle.Subtitles",
"references": [
"Unity.TextMeshPro",
"Unity.Timeline"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6d0f424a65eb1e748b892145abb71fc2
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
using System;
using UnityEngine.Playables;
namespace Streamingle.Subtitles
{
[Serializable]
public sealed class SubtitleBehaviour : PlayableBehaviour
{
public string Text = string.Empty;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6f48d9147e7499c4a9c572e2802ca17b
@@ -0,0 +1,27 @@
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
namespace Streamingle.Subtitles
{
public sealed class SubtitleClip : PlayableAsset, ITimelineClipAsset
{
[SerializeField, TextArea(1, 3)]
private string text = string.Empty;
public string Text
{
get => text;
set => text = value ?? string.Empty;
}
public ClipCaps clipCaps => ClipCaps.None;
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
{
var playable = ScriptPlayable<SubtitleBehaviour>.Create(graph);
playable.GetBehaviour().Text = text ?? string.Empty;
return playable;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 42cbc317c93c78348ab3c28587916567
@@ -0,0 +1,81 @@
using TMPro;
using UnityEngine;
using UnityEngine.Playables;
namespace Streamingle.Subtitles
{
public sealed class SubtitleMixerBehaviour : PlayableBehaviour
{
private const float MinimumWeight = 0.0001f;
private TMP_Text trackBinding;
private string appliedText;
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
SetBinding(playerData as TMP_Text);
if (trackBinding == null)
return;
var selectedText = string.Empty;
var greatestWeight = MinimumWeight;
var inputCount = playable.GetInputCount();
for (var index = 0; index < inputCount; index++)
{
var inputWeight = playable.GetInputWeight(index) * Mathf.Max(0f, info.effectiveWeight);
if (inputWeight <= greatestWeight)
continue;
var inputPlayable = (ScriptPlayable<SubtitleBehaviour>)playable.GetInput(index);
selectedText = inputPlayable.GetBehaviour().Text ?? string.Empty;
greatestWeight = inputWeight;
}
ApplyText(selectedText);
}
public override void OnGraphStop(Playable playable)
{
RestoreDefault();
}
public override void OnPlayableDestroy(Playable playable)
{
RestoreDefault();
}
private void SetBinding(TMP_Text target)
{
if (target == trackBinding)
return;
RestoreDefault();
trackBinding = target;
if (trackBinding == null)
return;
appliedText = null;
}
private void ApplyText(string value)
{
value ??= string.Empty;
if (appliedText == value && trackBinding.text == value)
return;
trackBinding.text = value;
appliedText = value;
}
private void RestoreDefault()
{
if (trackBinding == null)
return;
trackBinding.text = string.Empty;
trackBinding = null;
appliedText = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 66f86280161858943871bd56298c6153
@@ -0,0 +1,29 @@
using TMPro;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
namespace Streamingle.Subtitles
{
[TrackColor(0.16f, 0.72f, 0.86f)]
[TrackBindingType(typeof(TMP_Text))]
[TrackClipType(typeof(SubtitleClip))]
public sealed class SubtitleTrack : TrackAsset
{
public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
{
return ScriptPlayable<SubtitleMixerBehaviour>.Create(graph, inputCount);
}
#if UNITY_EDITOR
public override void GatherProperties(PlayableDirector director, IPropertyCollector collector)
{
var target = director != null ? director.GetGenericBinding(this) as TMP_Text : null;
if (target != null)
collector.AddFromName<TMP_Text>(target.gameObject, "m_text");
base.GatherProperties(director, collector);
}
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3577eec94a4b72246bf7e47a771787b2