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)); } [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced)] [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable)] public void ReducedPositionCurvesUseAuditedC1HermiteWithoutOvershoot( AICameraTimelinePreviewImporter.CurveSimplificationPreset preset) { var samples = CreateQuadraticMotionSamples(241); var simplified = Simplify(samples, preset); var curves = AICameraTimelinePreviewImporter.BuildPositionCurves( simplified, preset); Assert.That(curves.UsedExactFallback, Is.False, curves.FallbackReason); Assert.That(simplified.PositionUsedExactFallback, Is.False); Assert.That( curves.XCurve.length, Is.LessThan(samples.Times.Length)); AssertCurveIsC1(curves.XCurve); AssertCurveIsC1(curves.YCurve); AssertCurveIsC1(curves.ZCurve); AssertCurveDoesNotOvershootSelectedSegments( curves.XCurve, simplified.Times, simplified.PositionIndices); AssertCurveDoesNotOvershootSelectedSegments( curves.YCurve, simplified.Times, simplified.PositionIndices); AssertCurveDoesNotOvershootSelectedSegments( curves.ZCurve, simplified.Times, simplified.PositionIndices); var audit = AICameraTimelinePreviewImporter.AuditPositionCurves( simplified.Times, simplified.Positions, curves.XCurve, curves.YCurve, curves.ZCurve, AICameraTimelinePreviewImporter .GetCurveSimplificationSettings(preset) .PositionErrorMeters); Assert.That(audit.Passed, Is.True, audit.FailureReason); var rawMaximumJerk = MeasureMaximumJerk( simplified.Times, simplified.Positions); var evaluatedMaximumJerk = MeasureMaximumJerk( simplified.Times, EvaluatePositionCurves(simplified.Times, curves)); var allowedMaximumJerk = Mathf.Max( 250f, Mathf.Max(rawMaximumJerk * 3f, rawMaximumJerk + 150f)); Assert.That( evaluatedMaximumJerk, Is.LessThanOrEqualTo(allowedMaximumJerk)); } [Test] public void ReducedPositionCurveAuditFallsBackToExactPositionOnly() { const int count = 61; var times = new double[count]; var positions = new Vector3[count]; for (var index = 0; index < count; index++) { var time = index / 60.0; times[index] = time; positions[index] = new Vector3( Mathf.Sin((float)time * Mathf.PI * 2f), 1.6f, -4f); } var simplified = CreatePositionOnlySimplifiedCurves( times, positions, new[] { 0, count - 1 }); var curves = AICameraTimelinePreviewImporter.BuildPositionCurves( simplified, AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); Assert.That(curves.UsedExactFallback, Is.True); Assert.That(curves.FallbackReason, Does.Contain("position error")); Assert.That(simplified.PositionUsedExactFallback, Is.True); Assert.That( simplified.PositionFallbackReason, Is.EqualTo(curves.FallbackReason)); Assert.That(curves.XCurve.length, Is.EqualTo(count)); Assert.That(curves.YCurve.length, Is.EqualTo(count)); Assert.That(curves.ZCurve.length, Is.EqualTo(count)); for (var index = 0; index < count; index++) { Assert.That( curves.XCurve.Evaluate((float)times[index]), Is.EqualTo(positions[index].x).Within(0.000001f)); } } [Test] public void MotionAuditRejectsPathologicalMidpointVelocityRegression() { const int count = 61; var times = new double[count]; var positions = new Vector3[count]; for (var index = 0; index < count; index++) { var time = index / 60.0; times[index] = time; positions[index] = new Vector3((float)time, 0f, 0f); } var pathologicalX = new AnimationCurve( new Keyframe(0f, 0f, 1f, 80f), new Keyframe(1f, 1f, -80f, 1f)); var constant = AnimationCurve.Linear(0f, 0f, 1f, 0f); var audit = AICameraTimelinePreviewImporter.AuditPositionCurves( times, positions, pathologicalX, constant, constant, 100f); Assert.That(audit.Passed, Is.False); Assert.That(audit.FailureReason, Does.Contain("regressed")); } [Test] public void AuditGridUsesExactSourceTimesAndDropsFloatingTailDuplicates() { var frame = 1.0 / 60.0; var times = new[] { 0.0, frame, frame * 2.0, frame * 3.0, frame * 3.0 + 0.00000000005 }; var auditTimes = AICameraTimelinePreviewImporter .BuildPositionCurveAuditTimes(times); Assert.That(auditTimes.Count, Is.EqualTo(7)); Assert.That(auditTimes[0], Is.EqualTo(times[0])); Assert.That(auditTimes[2], Is.EqualTo(times[1])); Assert.That(auditTimes[4], Is.EqualTo(times[2])); Assert.That(auditTimes[6], Is.EqualTo(times[3])); for (var index = 1; index < auditTimes.Count; index++) { Assert.That( auditTimes[index] - auditTimes[index - 1], Is.GreaterThan(frame * 0.49), $"Audit time {index} introduced a floating-point tail."); } } [Test] public void AlignedMotionAuditFindsLocalJerkHiddenByEarlierGlobalMaximum() { const int count = 40; var times = new double[count]; var reference = new Vector3[count]; var candidate = new Vector3[count]; for (var index = 0; index < count; index++) { times[index] = index / 60.0; } reference[3] = new Vector3(0.1f, 0f, 0f); Array.Copy(reference, candidate, count); candidate[30] = new Vector3(0.001f, 0f, 0f); Assert.That( MeasureMaximumJerk(times, candidate), Is.EqualTo(MeasureMaximumJerk(times, reference)) .Within(0.01f), "The earlier source jolt must mask the new jolt in a global-max audit."); Assert.That( AICameraTimelinePreviewImporter.TryDescribeAlignedMotionRegression( times, reference, candidate, out var failureReason), Is.True); Assert.That(failureReason, Does.Contain("windowed jerk")); } [Test] public void MotionRetentionAuditRejectsErasedSubtleLowFrequencyMove() { const int count = 241; var times = new double[count]; var positions = new Vector3[count]; for (var index = 0; index < count; index++) { var time = index / 60.0; times[index] = time; positions[index] = new Vector3( 0.006f * Mathf.Sin((float)(time * Math.PI * 0.5)), 0f, 0f); } var erased = AnimationCurve.Linear( 0f, 0f, (float)times[count - 1], 0f); var audit = AICameraTimelinePreviewImporter.AuditPositionCurves( times, positions, erased, erased, erased, 0.02f); Assert.That(audit.Passed, Is.False); Assert.That( audit.FailureReason, Does.Contain("meaningful motion retention")); } [Test] public void MotionRetentionAuditIgnoresSubDeadbandPositionNoise() { const int count = 121; var times = new double[count]; var positions = new Vector3[count]; for (var index = 0; index < count; index++) { times[index] = index / 60.0; positions[index] = new Vector3( index % 2 == 0 ? 0.0001f : -0.0001f, 0f, 0f); } var denoised = AnimationCurve.Linear( 0f, 0f, (float)times[count - 1], 0f); var audit = AICameraTimelinePreviewImporter.AuditPositionCurves( times, positions, denoised, denoised, denoised, 0.02f); Assert.That(audit.Passed, Is.True, audit.FailureReason); } [Test] public void ExactPositionCurvesRetainEveryLinearSampleWithoutFallback() { var samples = CreateSamples(91); var simplified = Simplify( samples, AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact); var curves = AICameraTimelinePreviewImporter.BuildPositionCurves( simplified, AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact); Assert.That(curves.UsedExactFallback, Is.False); Assert.That(curves.XCurve.length, Is.EqualTo(samples.Times.Length)); Assert.That(curves.YCurve.length, Is.EqualTo(samples.Times.Length)); Assert.That(curves.ZCurve.length, Is.EqualTo(samples.Times.Length)); for (var index = 0; index < samples.Times.Length; index++) { var time = (float)samples.Times[index]; Assert.That( curves.XCurve.Evaluate(time), Is.EqualTo(samples.Positions[index].x).Within(0.000001f)); Assert.That( curves.YCurve.Evaluate(time), Is.EqualTo(samples.Positions[index].y).Within(0.000001f)); Assert.That( curves.ZCurve.Evaluate(time), Is.EqualTo(samples.Positions[index].z).Within(0.000001f)); } } [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 CreateQuadraticMotionSamples(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; var timeFloat = (float)time; times[index] = time; positions[index] = new Vector3( 0.4f * timeFloat + 0.15f * timeFloat * timeFloat, 1.6f + 0.02f * timeFloat * timeFloat, -4f + 0.1f * timeFloat); rotations[index] = Quaternion.identity; fieldOfView[index] = 40f; dutch[index] = 0f; } return new CameraSamples( times, positions, rotations, fieldOfView, dutch); } private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves CreatePositionOnlySimplifiedCurves( double[] times, Vector3[] positions, int[] positionIndices) { var rotations = Enumerable.Repeat( Quaternion.identity, times.Length) .ToArray(); var fieldOfView = Enumerable.Repeat(40f, times.Length).ToArray(); var dutch = new float[times.Length]; var allIndices = Enumerable.Range(0, times.Length).ToArray(); return new AICameraTimelinePreviewImporter.SimplifiedCameraCurves( times, positions, rotations, fieldOfView, dutch, positionIndices, allIndices, allIndices, allIndices); } private static void AssertCurveIsC1(AnimationCurve curve) { var keys = curve.keys; for (var index = 0; index < keys.Length; index++) { Assert.That( keys[index].inTangent, Is.EqualTo(keys[index].outTangent).Within(0.000001f), $"Curve key {index} has a velocity discontinuity."); } } private static void AssertCurveDoesNotOvershootSelectedSegments( AnimationCurve curve, IReadOnlyList times, IReadOnlyList indices) { const int samplesPerSegment = 16; for (var segment = 1; segment < indices.Count; segment++) { var start = (float)times[indices[segment - 1]]; var end = (float)times[indices[segment]]; var startValue = curve.Evaluate(start); var endValue = curve.Evaluate(end); var minimum = Mathf.Min(startValue, endValue) - 0.000001f; var maximum = Mathf.Max(startValue, endValue) + 0.000001f; for (var sample = 0; sample <= samplesPerSegment; sample++) { var time = Mathf.Lerp( start, end, sample / (float)samplesPerSegment); Assert.That( curve.Evaluate(time), Is.InRange(minimum, maximum), $"Curve overshot selected segment {segment - 1}."); } } } private static Vector3[] EvaluatePositionCurves( IReadOnlyList times, AICameraTimelinePreviewImporter.PositionCurveBuildResult curves) { var result = new Vector3[times.Count]; for (var index = 0; index < times.Count; index++) { var time = (float)times[index]; result[index] = new Vector3( curves.XCurve.Evaluate(time), curves.YCurve.Evaluate(time), curves.ZCurve.Evaluate(time)); } return result; } private static float MeasureMaximumJerk( IReadOnlyList times, IReadOnlyList positions) { if (times.Count < 4) { return 0f; } var velocities = new Vector3[times.Count - 1]; var velocityTimes = new double[velocities.Length]; for (var index = 0; index < velocities.Length; index++) { velocities[index] = (positions[index + 1] - positions[index]) / (float)(times[index + 1] - times[index]); velocityTimes[index] = (times[index + 1] + times[index]) * 0.5; } var accelerations = new Vector3[velocities.Length - 1]; var accelerationTimes = new double[accelerations.Length]; for (var index = 0; index < accelerations.Length; index++) { accelerations[index] = (velocities[index + 1] - velocities[index]) / (float)(velocityTimes[index + 1] - velocityTimes[index]); accelerationTimes[index] = (velocityTimes[index + 1] + velocityTimes[index]) * 0.5; } var maximumJerk = 0f; for (var index = 0; index < accelerations.Length - 1; index++) { var jerk = (accelerations[index + 1] - accelerations[index]) / (float)(accelerationTimes[index + 1] - accelerationTimes[index]); maximumJerk = Mathf.Max(maximumJerk, jerk.magnitude); } return maximumJerk; } 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; } } } }