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

647 lines
25 KiB
C#

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<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 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 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; }
}
}
}