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
@@ -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