using System.Collections.Generic; using System.Threading; using UnityEngine; using UnityEngine.Playables; #if UNITY_EDITOR using UnityEditor; #endif namespace Streamingle.Gaze { /// /// Blends Timeline clip requests into one command for the bound driver. /// public sealed class BlendshapeGazeMixerBehaviour : PlayableBehaviour { private const float MinimumWeight = 0.0001f; private static int nextSourceId; #if UNITY_EDITOR private static readonly HashSet PendingEditorEvaluations = new HashSet(); 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)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 UNITY_EDITOR 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); } #endif } 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 } }