using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace Streamingle.Editor { public sealed class AICameraCurveSimplifierTests { [Test] public void ExactPresetKeepsEverySampleAndNormalizesQuaternionSigns() { var samples = CreateSamples(121); var result = Simplify( samples, AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact); var expected = Enumerable.Range(0, samples.Times.Length).ToArray(); CollectionAssert.AreEqual(expected, result.PositionIndices); CollectionAssert.AreEqual(expected, result.RotationIndices); CollectionAssert.AreEqual(expected, result.FieldOfViewIndices); CollectionAssert.AreEqual(expected, result.DutchIndices); for (var index = 1; index < result.Rotations.Length; index++) { Assert.That( Quaternion.Dot( result.Rotations[index - 1], result.Rotations[index]), Is.GreaterThanOrEqualTo(0f), $"Quaternion sign changed at sample {index}."); } } [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced)] [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable)] public void ReducedPresetsRespectConfiguredVectorAngleAndLensErrors( AICameraTimelinePreviewImporter.CurveSimplificationPreset preset) { var samples = CreateSamples(241); var result = Simplify(samples, preset); var settings = AICameraTimelinePreviewImporter.GetCurveSimplificationSettings(preset); AssertVectorError( result, settings.PositionErrorMeters + 0.00001f); AssertQuaternionError( result, settings.QuaternionAngleErrorDegrees + 0.0001f); AssertScalarError( result.Times, result.FieldOfView, result.FieldOfViewIndices, settings.FieldOfViewErrorDegrees + 0.00001f, "FOV"); AssertScalarError( result.Times, result.Dutch, result.DutchIndices, settings.DutchErrorDegrees + 0.00001f, "Dutch"); } [Test] public void EveryChannelPreservesEndpointsAndGlobalExtrema() { var samples = CreateSamples(181); var result = Simplify( samples, AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); AssertEndpoints(result.PositionIndices, samples.Times.Length); AssertEndpoints(result.RotationIndices, samples.Times.Length); AssertEndpoints(result.FieldOfViewIndices, samples.Times.Length); AssertEndpoints(result.DutchIndices, samples.Times.Length); AssertExtrema( result.PositionIndices, result.Positions.Select(value => value.x).ToArray()); AssertExtrema( result.PositionIndices, result.Positions.Select(value => value.y).ToArray()); AssertExtrema( result.PositionIndices, result.Positions.Select(value => value.z).ToArray()); AssertExtrema(result.FieldOfViewIndices, result.FieldOfView); AssertExtrema(result.DutchIndices, result.Dutch); AssertExtrema( result.RotationIndices, result.Rotations.Select(value => value.x).ToArray()); AssertExtrema( result.RotationIndices, result.Rotations.Select(value => value.y).ToArray()); AssertExtrema( result.RotationIndices, result.Rotations.Select(value => value.z).ToArray()); AssertExtrema( result.RotationIndices, result.Rotations.Select(value => value.w).ToArray()); } [Test] public void QuaternionComponentsUseOneStrictlyIncreasingTimeIndexSet() { var samples = CreateSamples(151); var result = Simplify( samples, AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced); Assert.That(result.RotationIndices, Is.Ordered.Ascending); Assert.That( result.RotationIndices.Distinct().Count(), Is.EqualTo(result.RotationIndices.Length)); foreach (var index in result.RotationIndices) { Assert.That(index, Is.InRange(0, result.Times.Length - 1)); } for (var index = 1; index < result.Rotations.Length; index++) { Assert.That( Quaternion.Dot( result.Rotations[index - 1], result.Rotations[index]), Is.GreaterThanOrEqualTo(0f)); } } [Test] public void EditablePresetReducesSmoothCameraDataMoreThanExact() { var samples = CreateSamples(301); var exact = Simplify( samples, AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact); var editable = Simplify( samples, AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); Assert.That( editable.PositionIndices.Length, Is.LessThan(exact.PositionIndices.Length)); Assert.That( editable.RotationIndices.Length, Is.LessThan(exact.RotationIndices.Length)); Assert.That( editable.FieldOfViewIndices.Length, Is.LessThan(exact.FieldOfViewIndices.Length)); Assert.That( editable.DutchIndices.Length, Is.LessThan(exact.DutchIndices.Length)); } [Test] public void EditablePresetPreservesStopThroughDirectionChangeKeys() { const int count = 121; const int center = 60; const int preserveRadius = 21; var times = new double[count]; var positions = new Vector3[count]; var rotations = new Quaternion[count]; var fieldOfView = new float[count]; var dutch = new float[count]; for (var index = 0; index < count; index++) { times[index] = index / 60.0; float x; if (index <= center) { var t = index / (float)center; x = -t * t * t + t * t + t; } else { var t = (index - center) / (float)(count - 1 - center); var progress = -t * t * t + 2f * t * t; x = 1f - progress; } positions[index] = new Vector3(x, 1.6f, -4f); rotations[index] = Quaternion.identity; fieldOfView[index] = 40f; dutch[index] = 0f; } var result = Simplify( new CameraSamples( times, positions, rotations, fieldOfView, dutch), AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); for (var index = center - preserveRadius; index <= center + preserveRadius; index++) { Assert.That( result.PositionIndices, Does.Contain(index), $"Direction-change key {index} was simplified away."); } } [Test] public void EditablePresetPreservesBrakedRightAngleTurnKeys() { const int count = 121; const int center = 60; const int preserveRadius = 21; var times = new double[count]; var positions = new Vector3[count]; var rotations = new Quaternion[count]; var fieldOfView = new float[count]; var dutch = new float[count]; for (var index = 0; index < count; index++) { times[index] = index / 60.0; if (index <= center) { var t = index / (float)center; var progress = -t * t * t + t * t + t; positions[index] = new Vector3(progress, 1.6f, -4f); } else { var t = (index - center) / (float)(count - 1 - center); var progress = -t * t * t + 2f * t * t; positions[index] = new Vector3(1f, 1.6f, -4f + progress); } rotations[index] = Quaternion.identity; fieldOfView[index] = 40f; dutch[index] = 0f; } var result = Simplify( new CameraSamples( times, positions, rotations, fieldOfView, dutch), AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); for (var index = center - preserveRadius; index <= center + preserveRadius; index++) { Assert.That( result.PositionIndices, Does.Contain(index), $"Right-angle braking key {index} was simplified away."); } } [Test] public void EditablePresetPreservesBrakedFortyFiveDegreeTurnKeys() { const int count = 121; const int center = 60; const int preserveRadius = 21; var times = new double[count]; var positions = new Vector3[count]; var rotations = new Quaternion[count]; var fieldOfView = new float[count]; var dutch = new float[count]; var outgoing = new Vector3(1f, 0f, 1f).normalized; for (var index = 0; index < count; index++) { times[index] = index / 60.0; if (index <= center) { var t = index / (float)center; var progress = -t * t * t + t * t + t; positions[index] = new Vector3(progress, 1.6f, -4f); } else { var t = (index - center) / (float)(count - 1 - center); var progress = -t * t * t + 2f * t * t; positions[index] = new Vector3(1f, 1.6f, -4f) + outgoing * progress; } rotations[index] = Quaternion.identity; fieldOfView[index] = 40f; dutch[index] = 0f; } var result = Simplify( new CameraSamples( times, positions, rotations, fieldOfView, dutch), AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); for (var index = center - preserveRadius; index <= center + preserveRadius; index++) { Assert.That( result.PositionIndices, Does.Contain(index), $"Forty-five-degree braking key {index} was simplified away."); } } [Test] public void ExplicitDirectorScopesSeparateSameNamedSongDirectors() { var firstObject = new GameObject("SongDirector"); var secondObject = new GameObject("SongDirector"); var firstTimeline = ScriptableObject.CreateInstance(); var secondTimeline = ScriptableObject.CreateInstance(); try { var firstDirector = firstObject.AddComponent(); var secondDirector = secondObject.AddComponent(); firstDirector.playableAsset = firstTimeline; secondDirector.playableAsset = secondTimeline; var firstName = AICameraTimelinePreviewImporter .GetScopedPreviewDirectorName(firstDirector); var secondName = AICameraTimelinePreviewImporter .GetScopedPreviewDirectorName(secondDirector); StringAssert.StartsWith( AICameraTimelinePreviewImporter.PreviewDirectorName + "__", firstName); Assert.That(secondName, Is.Not.EqualTo(firstName)); } finally { UnityEngine.Object.DestroyImmediate(firstObject); UnityEngine.Object.DestroyImmediate(secondObject); UnityEngine.Object.DestroyImmediate(firstTimeline); UnityEngine.Object.DestroyImmediate(secondTimeline); } } [Test] public void PreviewDirectorDetectionAcceptsScopedGeneratedPreviewOnly() { var previewObject = new GameObject( AICameraTimelinePreviewImporter.PreviewDirectorName + "__Song_1234"); var ordinaryObject = new GameObject("Song"); var previewTimeline = ScriptableObject.CreateInstance(); var ordinaryTimeline = ScriptableObject.CreateInstance(); try { previewTimeline.CreateTrack( null, AICameraTimelinePreviewImporter.GeneratedAnimationTrackName); var preview = previewObject.AddComponent(); var ordinary = ordinaryObject.AddComponent(); preview.playableAsset = previewTimeline; ordinary.playableAsset = ordinaryTimeline; Assert.That( AICameraTimelinePreviewImporter .IsPreviewDirectorForEditor(preview), Is.True); Assert.That( AICameraTimelinePreviewImporter .IsPreviewDirectorForEditor(ordinary), Is.False); } finally { UnityEngine.Object.DestroyImmediate(previewObject); UnityEngine.Object.DestroyImmediate(ordinaryObject); UnityEngine.Object.DestroyImmediate(previewTimeline); UnityEngine.Object.DestroyImmediate(ordinaryTimeline); } } [Test] public void FinalAssetFolderMustStayBelowAssets() { Assert.That( AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder( "Assets/Shows/AI_CameraFinal/SongA/"), Is.EqualTo("Assets/Shows/AI_CameraFinal/SongA")); Assert.Throws(() => AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder( "Assets/../Outside")); Assert.Throws(() => AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder( "C:/Outside")); Assert.Throws(() => AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder( "Assets")); } [Test] public void FinalizationClearsDisposablePreviewTrackBindings() { var directorObject = new GameObject("FinalDestinationDirector"); var animatorObject = new GameObject("DisposablePreviewAnimator"); var timeline = ScriptableObject.CreateInstance(); try { var director = directorObject.AddComponent(); var animator = animatorObject.AddComponent(); var track = timeline.CreateTrack( null, "Disposable Preview Track"); director.playableAsset = timeline; director.SetGenericBinding(track, animator); Assert.That(director.GetGenericBinding(track), Is.SameAs(animator)); AICameraTimelinePreviewImporter .ClearTimelineBindingsForFinalization(director, timeline); Assert.That(director.GetGenericBinding(track), Is.Null); } finally { UnityEngine.Object.DestroyImmediate(directorObject); UnityEngine.Object.DestroyImmediate(animatorObject); UnityEngine.Object.DestroyImmediate(timeline); } } private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves Simplify( CameraSamples samples, AICameraTimelinePreviewImporter.CurveSimplificationPreset preset) { return AICameraTimelinePreviewImporter.SimplifyCameraCurves( samples.Times, samples.Positions, samples.Rotations, samples.FieldOfView, samples.Dutch, preset); } private static CameraSamples CreateSamples(int count) { var times = new double[count]; var positions = new Vector3[count]; var rotations = new Quaternion[count]; var fieldOfView = new float[count]; var dutch = new float[count]; for (var index = 0; index < count; index++) { var time = index / 60.0; times[index] = time; positions[index] = new Vector3( 1.2f * Mathf.Sin((float)time * 0.8f), 1.6f + 0.25f * Mathf.Cos((float)time * 1.1f), -3.2f + 0.7f * Mathf.Sin((float)time * 0.45f)); var rotation = Quaternion.Euler( 8f * Mathf.Sin((float)time * 0.7f), 35f * Mathf.Sin((float)time * 0.35f), 3f * Mathf.Cos((float)time * 0.9f)); rotations[index] = index % 11 == 0 ? new Quaternion( -rotation.x, -rotation.y, -rotation.z, -rotation.w) : rotation; fieldOfView[index] = 42f + 7f * Mathf.Sin((float)time * 0.55f); dutch[index] = 2.5f * Mathf.Cos((float)time * 0.75f); } return new CameraSamples( times, positions, rotations, fieldOfView, dutch); } private static void AssertVectorError( AICameraTimelinePreviewImporter.SimplifiedCameraCurves result, float maximumError) { ForEverySegmentSample( result.Times, result.PositionIndices, (sample, left, right, blend) => { var reconstructed = Vector3.LerpUnclamped( result.Positions[left], result.Positions[right], blend); Assert.That( Vector3.Distance(result.Positions[sample], reconstructed), Is.LessThanOrEqualTo(maximumError), $"Position error exceeded at sample {sample}."); }); } private static void AssertQuaternionError( AICameraTimelinePreviewImporter.SimplifiedCameraCurves result, float maximumError) { ForEverySegmentSample( result.Times, result.RotationIndices, (sample, left, right, blend) => { var reconstructed = Normalize(new Quaternion( Mathf.LerpUnclamped( result.Rotations[left].x, result.Rotations[right].x, blend), Mathf.LerpUnclamped( result.Rotations[left].y, result.Rotations[right].y, blend), Mathf.LerpUnclamped( result.Rotations[left].z, result.Rotations[right].z, blend), Mathf.LerpUnclamped( result.Rotations[left].w, result.Rotations[right].w, blend))); Assert.That( Quaternion.Angle(result.Rotations[sample], reconstructed), Is.LessThanOrEqualTo(maximumError), $"Quaternion angle error exceeded at sample {sample}."); }); } private static void AssertScalarError( IReadOnlyList times, IReadOnlyList values, IReadOnlyList indices, float maximumError, string label) { ForEverySegmentSample( times, indices, (sample, left, right, blend) => { var reconstructed = Mathf.LerpUnclamped( values[left], values[right], blend); Assert.That( Mathf.Abs(values[sample] - reconstructed), Is.LessThanOrEqualTo(maximumError), $"{label} error exceeded at sample {sample}."); }); } private static void ForEverySegmentSample( IReadOnlyList times, IReadOnlyList indices, Action assertion) { for (var segmentIndex = 1; segmentIndex < indices.Count; segmentIndex++) { var left = indices[segmentIndex - 1]; var right = indices[segmentIndex]; var duration = times[right] - times[left]; for (var sample = left; sample <= right; sample++) { var blend = duration > double.Epsilon ? (float)((times[sample] - times[left]) / duration) : 0f; assertion(sample, left, right, blend); } } } private static void AssertEndpoints(IReadOnlyCollection indices, int count) { Assert.That(indices, Does.Contain(0)); Assert.That(indices, Does.Contain(count - 1)); } private static void AssertExtrema( IReadOnlyCollection indices, IReadOnlyList values) { var minimum = Enumerable.Range(0, values.Count) .OrderBy(index => values[index]) .First(); var maximum = Enumerable.Range(0, values.Count) .OrderByDescending(index => values[index]) .First(); Assert.That(indices, Does.Contain(minimum)); Assert.That(indices, Does.Contain(maximum)); } private static Quaternion Normalize(Quaternion value) { var magnitude = Mathf.Sqrt( value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w); return new Quaternion( value.x / magnitude, value.y / magnitude, value.z / magnitude, value.w / magnitude); } private readonly struct CameraSamples { public CameraSamples( double[] times, Vector3[] positions, Quaternion[] rotations, float[] fieldOfView, float[] dutch) { Times = times; Positions = positions; Rotations = rotations; FieldOfView = fieldOfView; Dutch = dutch; } public double[] Times { get; } public Vector3[] Positions { get; } public Quaternion[] Rotations { get; } public float[] FieldOfView { get; } public float[] Dutch { get; } } } }