streamingle-unity-utilities/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs

1750 lines
67 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEditor;
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 ReducedPositionCurvesUseAuditedC2SplineWithoutOvershoot(
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));
AssertCurveIsC2(curves.XCurve);
AssertCurveIsC2(curves.YCurve);
AssertCurveIsC2(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 SparsePositionErrorIsAdaptivelyRefinedWithoutDenseFallback()
{
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.False, curves.FallbackReason);
Assert.That(simplified.PositionUsedExactFallback, Is.False);
Assert.That(curves.XCurve.length, Is.GreaterThan(2));
Assert.That(curves.XCurve.length, Is.LessThan(count));
AssertCurveIsC2(curves.XCurve);
AssertCurveIsC2(curves.YCurve);
AssertCurveIsC2(curves.ZCurve);
var settings = AICameraTimelinePreviewImporter
.GetCurveSimplificationSettings(
AICameraTimelinePreviewImporter
.CurveSimplificationPreset.Editable);
for (var index = 0; index < count; index++)
{
Assert.That(
Mathf.Abs(
curves.XCurve.Evaluate((float)times[index]) -
positions[index].x),
Is.LessThanOrEqualTo(
settings.PositionErrorMeters + 0.0001f));
}
}
[Test]
public void HighFrequencyPositionExceedingRefinementBudgetUsesAuditedDenseFallback()
{
const int count = 1201;
var times = new double[count];
var positions = new Vector3[count];
for (var index = 0; index < count; index++)
{
var time = index / 60f;
times[index] = time;
positions[index] = new Vector3(
Mathf.Sin(time * Mathf.PI * 6f),
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.XCurve.length, Is.EqualTo(count));
AssertCurveIsC2(curves.XCurve);
var audit = AICameraTimelinePreviewImporter.AuditPositionCurves(
times,
positions,
curves.XCurve,
curves.YCurve,
curves.ZCurve,
AICameraTimelinePreviewImporter
.GetCurveSimplificationSettings(
AICameraTimelinePreviewImporter
.CurveSimplificationPreset.Editable)
.PositionErrorMeters);
Assert.That(audit.Passed, Is.True, audit.FailureReason);
}
[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").Or.Contain("not C1").Or.Contain("not C2"));
}
[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 ExactPositionCurvesRetainEverySampleWithC2Tangents()
{
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));
AssertCurveIsC2(curves.XCurve);
AssertCurveIsC2(curves.YCurve);
AssertCurveIsC2(curves.ZCurve);
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 ExactPositionCurvesRejectUnboundedBetweenFrameJerk()
{
var times = new[]
{
0.0,
1.0 / 60.0,
2.0 / 60.0,
2.0
};
var positions = new[]
{
new Vector3(0f, 1.6f, -4f),
new Vector3(0f, 1.6f, -4f),
new Vector3(1f, 1.6f, -4f),
new Vector3(1f, 1.6f, -4f)
};
var simplified = CreatePositionOnlySimplifiedCurves(
times,
positions,
Enumerable.Range(0, times.Length).ToArray());
var exception = Assert.Throws<InvalidOperationException>(() =>
AICameraTimelinePreviewImporter.BuildPositionCurves(
simplified,
AICameraTimelinePreviewImporter
.CurveSimplificationPreset.Exact));
Assert.That(
exception.Message,
Does.Contain("Exact dense C2 position curves failed"));
Assert.That(exception.Message, Does.Contain("high-rate motion audit"));
}
[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<TimelineAsset>();
var secondTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var firstDirector = firstObject.AddComponent<PlayableDirector>();
var secondDirector = secondObject.AddComponent<PlayableDirector>();
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 C1OnlyCurveIsRejectedAndItsJerkDivergesWithSampleRate()
{
var curve = new AnimationCurve(
new Keyframe(0f, 0f, 0f, 0f),
new Keyframe(1f, 1f, 0f, 0f),
new Keyframe(2f, 1f, 0f, 0f));
AssertCurveIsC1(curve);
Assert.That(
AICameraTimelinePreviewImporter.TryDescribeC2CurveFailure(
curve,
"C1-only fixture",
out var failureReason),
Is.True);
Assert.That(failureReason, Does.Contain("not C2"));
var jerk120 = MeasureMaximumJerk(
CreateUniformTimes(0f, 2f, 120),
EvaluateScalarCurveAsPositions(curve, 0f, 2f, 120));
var jerk240 = MeasureMaximumJerk(
CreateUniformTimes(0f, 2f, 240),
EvaluateScalarCurveAsPositions(curve, 0f, 2f, 240));
var jerk480 = MeasureMaximumJerk(
CreateUniformTimes(0f, 2f, 480),
EvaluateScalarCurveAsPositions(curve, 0f, 2f, 480));
Assert.That(jerk240, Is.GreaterThan(jerk120 * 1.7f));
Assert.That(jerk480, Is.GreaterThan(jerk240 * 1.7f));
}
[Test]
public void PreviewDirectorDetectionAcceptsScopedGeneratedPreviewOnly()
{
var previewObject = new GameObject(
AICameraTimelinePreviewImporter.PreviewDirectorName + "__Song_1234");
var ordinaryObject = new GameObject("Song");
var previewTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
var ordinaryTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
previewTimeline.CreateTrack<AnimationTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var preview = previewObject.AddComponent<PlayableDirector>();
var ordinary = ordinaryObject.AddComponent<PlayableDirector>();
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<ArgumentException>(() =>
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"Assets/../Outside"));
Assert.Throws<ArgumentException>(() =>
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"C:/Outside"));
Assert.Throws<ArgumentException>(() =>
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"Assets"));
}
[Test]
public void FinalizationClearsDisposablePreviewTrackBindings()
{
var directorObject = new GameObject("FinalDestinationDirector");
var animatorObject = new GameObject("DisposablePreviewAnimator");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
var animator = animatorObject.AddComponent<Animator>();
var track = timeline.CreateTrack<AnimationTrack>(
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 AICameraTimelinePreviewImporter.SimplifiedCameraCurves
CreateRotationOnlySimplifiedCurves(
double[] times,
Quaternion[] rotations,
AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
{
var positions = Enumerable.Repeat(Vector3.zero, times.Length).ToArray();
var fieldOfView = Enumerable.Repeat(40f, times.Length).ToArray();
var dutch = new float[times.Length];
return AICameraTimelinePreviewImporter.SimplifyCameraCurves(
times,
positions,
rotations,
fieldOfView,
dutch,
preset);
}
private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves
CreateRotationOnlySimplifiedCurves(
double[] times,
Quaternion[] rotations,
int[] rotationIndices)
{
var positions = Enumerable.Repeat(Vector3.zero, 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,
allIndices,
rotationIndices,
allIndices,
allIndices);
}
private static AnimationCurve BuildQuaternionCornerCurve(
IReadOnlyList<float> times,
IReadOnlyList<float> values)
{
var firstSlope =
(values[1] - values[0]) / (times[1] - times[0]);
var secondSlope =
(values[2] - values[1]) / (times[2] - times[1]);
return new AnimationCurve(
new Keyframe(
times[0],
values[0],
firstSlope,
firstSlope),
new Keyframe(
times[1],
values[1],
firstSlope,
secondSlope),
new Keyframe(
times[2],
values[2],
secondSlope,
secondSlope));
}
private static void SetRotationCurve(
AnimationClip clip,
string propertyName,
AnimationCurve curve)
{
AnimationUtility.SetEditorCurve(
clip,
EditorCurveBinding.FloatCurve(
string.Empty,
typeof(Transform),
propertyName),
curve);
}
private static AnimationCurve GetRotationCurve(
AnimationClip clip,
string propertyName)
{
return AnimationUtility.GetEditorCurve(
clip,
EditorCurveBinding.FloatCurve(
string.Empty,
typeof(Transform),
propertyName));
}
private static void AssertQuaternionEndpoints(
IReadOnlyList<double> times,
IReadOnlyList<Quaternion> rotations,
AICameraTimelinePreviewImporter.RotationCurveBuildResult curves)
{
var endpointTimes = new[] { times[0], times[times.Count - 1] };
var evaluated = AICameraTimelinePreviewImporter.EvaluateRotationCurves(
endpointTimes,
curves);
Assert.That(
Quaternion.Angle(rotations[0], evaluated[0]),
Is.LessThan(0.05f));
Assert.That(
Quaternion.Angle(rotations[rotations.Count - 1], evaluated[1]),
Is.LessThan(0.05f));
}
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 AssertCurveIsC2(AnimationCurve curve)
{
AssertCurveIsC1(curve);
var failed = AICameraTimelinePreviewImporter.TryDescribeC2CurveFailure(
curve,
"test curve",
out var failureReason);
Assert.That(failed, Is.False, failureReason);
}
private static void AssertCurveDoesNotOvershootSelectedSegments(
AnimationCurve curve,
IReadOnlyList<double> times,
IReadOnlyList<int> 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}.");
}
}
}
[Test]
public void Shot68StyleLongRotationUsesAuditedSparseC2Curves()
{
const int count = 1081;
var times = new double[count];
var rotations = new Quaternion[count];
for (var index = 0; index < count; index++)
{
var time = index / 60f;
var normalized = index / (float)(count - 1);
var eased = normalized * normalized * (3f - 2f * normalized);
times[index] = time;
rotations[index] = Quaternion.Euler(
-3f + 1.25f * Mathf.Sin(time * 0.22f),
-24f + 48f * eased + 1.5f * Mathf.Sin(time * 0.3f),
0.6f * Mathf.Sin(time * 0.27f));
}
var simplified = CreateRotationOnlySimplifiedCurves(
times,
rotations,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
var curves = AICameraTimelinePreviewImporter.BuildRotationCurves(
simplified,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
Assert.That(
simplified.RotationIndices.Length,
Is.LessThan(count / 4),
"The long smooth shot should remain meaningfully editable.");
Assert.That(curves.UsedDenseFallback, Is.False, curves.FallbackReason);
Assert.That(curves.XCurve.length, Is.LessThan(count / 4));
AssertCurveIsC2(curves.XCurve);
AssertCurveIsC2(curves.YCurve);
AssertCurveIsC2(curves.ZCurve);
AssertCurveIsC2(curves.WCurve);
AssertQuaternionEndpoints(times, rotations, curves);
var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
times,
rotations,
curves.XCurve,
curves.YCurve,
curves.ZCurve,
curves.WCurve,
AICameraTimelinePreviewImporter
.GetCurveSimplificationSettings(
AICameraTimelinePreviewImporter
.CurveSimplificationPreset.Balanced)
.QuaternionAngleErrorDegrees);
Assert.That(audit.Passed, Is.True, audit.FailureReason);
}
[Test]
public void AngularMotionAuditRejectsSparseLinearVelocityCorner()
{
const int count = 121;
var times = new double[count];
var rotations = new Quaternion[count];
for (var index = 0; index < count; index++)
{
var time = index / 60f;
times[index] = time;
rotations[index] = Quaternion.Euler(0f, 15f * time, 0f);
}
var cornerRotations = new[]
{
Quaternion.Euler(0f, 0f, 0f),
Quaternion.Euler(0f, 10f, 0f),
Quaternion.Euler(0f, 30f, 0f)
};
var cornerTimes = new[] { 0f, 1f, 2f };
var xCurve = BuildQuaternionCornerCurve(
cornerTimes,
cornerRotations.Select(value => value.x).ToArray());
var yCurve = BuildQuaternionCornerCurve(
cornerTimes,
cornerRotations.Select(value => value.y).ToArray());
var zCurve = BuildQuaternionCornerCurve(
cornerTimes,
cornerRotations.Select(value => value.z).ToArray());
var wCurve = BuildQuaternionCornerCurve(
cornerTimes,
cornerRotations.Select(value => value.w).ToArray());
var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
times,
rotations,
xCurve,
yCurve,
zCurve,
wCurve,
180f);
Assert.That(audit.Passed, Is.False);
Assert.That(
audit.FailureReason,
Does.Contain("angular").Or.Contain("rotation"));
}
[Test]
public void Shot66StyleMissedCorrectionIsAdaptivelyRefinedAsC2Rotation()
{
const int count = 121;
var times = new double[count];
var rotations = new Quaternion[count];
for (var index = 0; index < count; index++)
{
var time = index / 60f;
var correctionDistance = (index - 82f) / 8f;
var correction = 3f * Mathf.Exp(
-correctionDistance * correctionDistance);
times[index] = time;
rotations[index] = Quaternion.Euler(
0.4f * Mathf.Sin(time),
8f * time + correction,
0f);
}
var simplified = CreateRotationOnlySimplifiedCurves(
times,
rotations,
new[] { 0, count - 1 });
var curves = AICameraTimelinePreviewImporter.BuildRotationCurves(
simplified,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);
Assert.That(curves.UsedDenseFallback, Is.False, curves.FallbackReason);
Assert.That(simplified.RotationUsedDenseFallback, Is.False);
Assert.That(curves.XCurve.length, Is.GreaterThan(2));
Assert.That(curves.XCurve.length, Is.LessThan(count));
AssertCurveIsC2(curves.XCurve);
AssertCurveIsC2(curves.YCurve);
AssertCurveIsC2(curves.ZCurve);
AssertCurveIsC2(curves.WCurve);
AssertQuaternionEndpoints(times, rotations, curves);
var evaluated = AICameraTimelinePreviewImporter.EvaluateRotationCurves(
times,
curves);
for (var index = 0; index < count; index++)
{
Assert.That(
Quaternion.Angle(rotations[index], evaluated[index]),
Is.LessThan(0.51f),
$"Adaptive C2 refinement missed source rotation {index}.");
}
}
[Test]
public void HighFrequencyRotationExceedingRefinementBudgetUsesDenseC2Fallback()
{
const int count = 1201;
var times = new double[count];
var rotations = new Quaternion[count];
for (var index = 0; index < count; index++)
{
var time = index / 60f;
times[index] = time;
rotations[index] = Quaternion.Euler(
0f,
10f * Mathf.Sin(time * Mathf.PI * 6f),
0f);
}
var simplified = CreateRotationOnlySimplifiedCurves(
times,
rotations,
new[] { 0, count - 1 });
var curves = AICameraTimelinePreviewImporter.BuildRotationCurves(
simplified,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);
Assert.That(curves.UsedDenseFallback, Is.True);
Assert.That(curves.XCurve.length, Is.EqualTo(count));
AssertCurveIsC2(curves.XCurve);
AssertCurveIsC2(curves.YCurve);
AssertCurveIsC2(curves.ZCurve);
AssertCurveIsC2(curves.WCurve);
var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
times,
rotations,
curves.XCurve,
curves.YCurve,
curves.ZCurve,
curves.WCurve,
AICameraTimelinePreviewImporter
.GetCurveSimplificationSettings(
AICameraTimelinePreviewImporter
.CurveSimplificationPreset.Editable)
.QuaternionAngleErrorDegrees);
Assert.That(audit.Passed, Is.True, audit.FailureReason);
}
[Test]
public void UnityQuaternionContinuityPassPreservesAuditedC2Curves()
{
const int count = 241;
var times = new double[count];
var rotations = new Quaternion[count];
for (var index = 0; index < count; index++)
{
var time = index / 60f;
times[index] = time;
rotations[index] = Quaternion.Euler(
2f * Mathf.Sin(time * 0.45f),
12f * Mathf.Sin(time * 0.3f),
0.7f * Mathf.Sin(time * 0.6f));
}
var simplified = CreateRotationOnlySimplifiedCurves(
times,
rotations,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
var curves = AICameraTimelinePreviewImporter.BuildRotationCurves(
simplified,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
var clip = new AnimationClip();
try
{
SetRotationCurve(clip, "m_LocalRotation.x", curves.XCurve);
SetRotationCurve(clip, "m_LocalRotation.y", curves.YCurve);
SetRotationCurve(clip, "m_LocalRotation.z", curves.ZCurve);
SetRotationCurve(clip, "m_LocalRotation.w", curves.WCurve);
clip.EnsureQuaternionContinuity();
var xCurve = GetRotationCurve(clip, "m_LocalRotation.x");
var yCurve = GetRotationCurve(clip, "m_LocalRotation.y");
var zCurve = GetRotationCurve(clip, "m_LocalRotation.z");
var wCurve = GetRotationCurve(clip, "m_LocalRotation.w");
AssertCurveIsC2(xCurve);
AssertCurveIsC2(yCurve);
AssertCurveIsC2(zCurve);
AssertCurveIsC2(wCurve);
var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
times,
rotations,
xCurve,
yCurve,
zCurve,
wCurve,
AICameraTimelinePreviewImporter
.GetCurveSimplificationSettings(
AICameraTimelinePreviewImporter
.CurveSimplificationPreset.Balanced)
.QuaternionAngleErrorDegrees);
Assert.That(audit.Passed, Is.True, audit.FailureReason);
}
finally
{
UnityEngine.Object.DestroyImmediate(clip);
}
}
[Test]
public void Crossing180WithSignFlipsStaysC2AndSamplesWithoutNearZero()
{
const int count = 241;
var times = new double[count];
var rotations = new Quaternion[count];
for (var index = 0; index < count; index++)
{
var time = index / 60f;
times[index] = time;
var rotation = Quaternion.Euler(
1.5f * Mathf.Sin(time * 0.4f),
170f + 10f * time,
0.5f * Mathf.Sin(time * 0.7f));
rotations[index] = index % 7 == 0
? new Quaternion(
-rotation.x,
-rotation.y,
-rotation.z,
-rotation.w)
: rotation;
}
var simplified = CreateRotationOnlySimplifiedCurves(
times,
rotations,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
var curves = AICameraTimelinePreviewImporter.BuildRotationCurves(
simplified,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
var clip = new AnimationClip();
var target = new GameObject("QuaternionSampleTarget");
try
{
SetRotationCurve(clip, "m_LocalRotation.x", curves.XCurve);
SetRotationCurve(clip, "m_LocalRotation.y", curves.YCurve);
SetRotationCurve(clip, "m_LocalRotation.z", curves.ZCurve);
SetRotationCurve(clip, "m_LocalRotation.w", curves.WCurve);
clip.EnsureQuaternionContinuity();
var xCurve = GetRotationCurve(clip, "m_LocalRotation.x");
var yCurve = GetRotationCurve(clip, "m_LocalRotation.y");
var zCurve = GetRotationCurve(clip, "m_LocalRotation.z");
var wCurve = GetRotationCurve(clip, "m_LocalRotation.w");
AssertCurveIsC2(xCurve);
AssertCurveIsC2(yCurve);
AssertCurveIsC2(zCurve);
AssertCurveIsC2(wCurve);
var previous = Quaternion.identity;
var auditTimes = AICameraTimelinePreviewImporter
.BuildUniformCurveAuditTimes(times, 240);
for (var index = 0; index < auditTimes.Count; index++)
{
var time = (float)auditTimes[index];
var raw = new Quaternion(
xCurve.Evaluate(time),
yCurve.Evaluate(time),
zCurve.Evaluate(time),
wCurve.Evaluate(time));
var magnitude = Mathf.Sqrt(
raw.x * raw.x +
raw.y * raw.y +
raw.z * raw.z +
raw.w * raw.w);
Assert.That(
magnitude,
Is.GreaterThan(0.9f),
$"Quaternion curve approached zero at {time:F4} s.");
var expected = Normalize(raw);
if (index > 0 && Quaternion.Dot(previous, expected) < 0f)
{
expected = new Quaternion(
-expected.x,
-expected.y,
-expected.z,
-expected.w);
}
clip.SampleAnimation(target, time);
Assert.That(
Quaternion.Angle(expected, target.transform.localRotation),
Is.LessThan(0.1f),
$"SampleAnimation diverged at {time:F4} s.");
previous = expected;
}
var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
times,
rotations,
xCurve,
yCurve,
zCurve,
wCurve,
AICameraTimelinePreviewImporter
.GetCurveSimplificationSettings(
AICameraTimelinePreviewImporter
.CurveSimplificationPreset.Balanced)
.QuaternionAngleErrorDegrees);
Assert.That(audit.Passed, Is.True, audit.FailureReason);
}
finally
{
UnityEngine.Object.DestroyImmediate(target);
UnityEngine.Object.DestroyImmediate(clip);
}
}
[Test]
public void RotationAuditRejectsNearZeroQuaternionCurve()
{
var times = new[] { 0.0, 1.0 };
var rotations = new[] { Quaternion.identity, Quaternion.identity };
var zero = AnimationCurve.Linear(0f, 0f, 1f, 0f);
var audit = AICameraTimelinePreviewImporter.AuditRotationCurves(
times,
rotations,
zero,
zero,
zero,
zero,
180f);
Assert.That(audit.Passed, Is.False);
Assert.That(audit.FailureReason, Does.Contain("zero quaternion"));
}
private static Vector3[] EvaluatePositionCurves(
IReadOnlyList<double> 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<double> times,
IReadOnlyList<Vector3> 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 double[] CreateUniformTimes(
float startTime,
float endTime,
int samplesPerSecond)
{
var intervalCount = Mathf.Max(
1,
Mathf.CeilToInt((endTime - startTime) * samplesPerSecond));
var times = new double[intervalCount + 1];
for (var index = 0; index <= intervalCount; index++)
{
times[index] = (float)Mathf.Lerp(
startTime,
endTime,
index / (float)intervalCount);
}
return times;
}
private static Vector3[] EvaluateScalarCurveAsPositions(
AnimationCurve curve,
float startTime,
float endTime,
int samplesPerSecond)
{
var times = CreateUniformTimes(
startTime,
endTime,
samplesPerSecond);
var positions = new Vector3[times.Length];
for (var index = 0; index < times.Length; index++)
{
positions[index] = new Vector3(
curve.Evaluate((float)times[index]),
0f,
0f);
}
return positions;
}
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<double> times,
IReadOnlyList<float> values,
IReadOnlyList<int> 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<double> times,
IReadOnlyList<int> indices,
Action<int, int, int, float> 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<int> indices, int count)
{
Assert.That(indices, Does.Contain(0));
Assert.That(indices, Does.Contain(count - 1));
}
private static void AssertExtrema(
IReadOnlyCollection<int> indices,
IReadOnlyList<float> 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; }
}
}
}