1088 lines
41 KiB
C#
1088 lines
41 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));
|
|
}
|
|
|
|
[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<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 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<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}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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; }
|
|
}
|
|
}
|
|
}
|