fix(camera-ai): smooth generated camera curves
This commit is contained in:
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Playables;
|
||||
using UnityEngine.Timeline;
|
||||
@@ -157,7 +158,7 @@ namespace Streamingle.Editor
|
||||
|
||||
[TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced)]
|
||||
[TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable)]
|
||||
public void ReducedPositionCurvesUseAuditedC1HermiteWithoutOvershoot(
|
||||
public void ReducedPositionCurvesUseAuditedC2SplineWithoutOvershoot(
|
||||
AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
|
||||
{
|
||||
var samples = CreateQuadraticMotionSamples(241);
|
||||
@@ -172,9 +173,9 @@ namespace Streamingle.Editor
|
||||
Assert.That(
|
||||
curves.XCurve.length,
|
||||
Is.LessThan(samples.Times.Length));
|
||||
AssertCurveIsC1(curves.XCurve);
|
||||
AssertCurveIsC1(curves.YCurve);
|
||||
AssertCurveIsC1(curves.ZCurve);
|
||||
AssertCurveIsC2(curves.XCurve);
|
||||
AssertCurveIsC2(curves.YCurve);
|
||||
AssertCurveIsC2(curves.ZCurve);
|
||||
AssertCurveDoesNotOvershootSelectedSegments(
|
||||
curves.XCurve,
|
||||
simplified.Times,
|
||||
@@ -214,7 +215,7 @@ namespace Streamingle.Editor
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReducedPositionCurveAuditFallsBackToExactPositionOnly()
|
||||
public void SparsePositionErrorIsAdaptivelyRefinedWithoutDenseFallback()
|
||||
{
|
||||
const int count = 61;
|
||||
var times = new double[count];
|
||||
@@ -237,23 +238,69 @@ namespace Streamingle.Editor
|
||||
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));
|
||||
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(
|
||||
curves.XCurve.Evaluate((float)times[index]),
|
||||
Is.EqualTo(positions[index].x).Within(0.000001f));
|
||||
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()
|
||||
{
|
||||
@@ -281,7 +328,9 @@ namespace Streamingle.Editor
|
||||
100f);
|
||||
|
||||
Assert.That(audit.Passed, Is.False);
|
||||
Assert.That(audit.FailureReason, Does.Contain("regressed"));
|
||||
Assert.That(
|
||||
audit.FailureReason,
|
||||
Does.Contain("regressed").Or.Contain("not C1").Or.Contain("not C2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -412,7 +461,7 @@ namespace Streamingle.Editor
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExactPositionCurvesRetainEveryLinearSampleWithoutFallback()
|
||||
public void ExactPositionCurvesRetainEverySampleWithC2Tangents()
|
||||
{
|
||||
var samples = CreateSamples(91);
|
||||
var simplified = Simplify(
|
||||
@@ -427,6 +476,9 @@ namespace Streamingle.Editor
|
||||
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];
|
||||
@@ -442,6 +494,40 @@ namespace Streamingle.Editor
|
||||
}
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
@@ -637,6 +723,37 @@ namespace Streamingle.Editor
|
||||
}
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
@@ -789,6 +906,115 @@ namespace Streamingle.Editor
|
||||
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;
|
||||
@@ -801,6 +1027,16 @@ namespace Streamingle.Editor
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -829,6 +1065,390 @@ namespace Streamingle.Editor
|
||||
}
|
||||
}
|
||||
|
||||
[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)
|
||||
@@ -889,6 +1509,48 @@ namespace Streamingle.Editor
|
||||
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];
|
||||
|
||||
@@ -81,12 +81,14 @@ namespace Streamingle.Editor.Tests
|
||||
Assert.That(
|
||||
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
|
||||
project,
|
||||
configured),
|
||||
configured,
|
||||
Path.Combine(root, "MissingPackage")),
|
||||
Is.EqualTo(Path.GetFullPath(configured)));
|
||||
Assert.That(
|
||||
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
|
||||
project,
|
||||
string.Empty),
|
||||
string.Empty,
|
||||
Path.Combine(root, "MissingPackage")),
|
||||
Is.EqualTo(Path.GetFullPath(sibling)));
|
||||
}
|
||||
finally
|
||||
@@ -117,7 +119,8 @@ namespace Streamingle.Editor.Tests
|
||||
Assert.That(
|
||||
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
|
||||
project,
|
||||
project),
|
||||
project,
|
||||
Path.Combine(root, "MissingPackage")),
|
||||
Is.EqualTo(Path.GetFullPath(sibling)));
|
||||
}
|
||||
finally
|
||||
@@ -141,7 +144,8 @@ namespace Streamingle.Editor.Tests
|
||||
Assert.That(
|
||||
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
|
||||
project,
|
||||
"\0invalid"),
|
||||
"\0invalid",
|
||||
Path.Combine(root, "MissingPackage")),
|
||||
Is.EqualTo(Path.GetFullPath(sibling)));
|
||||
}
|
||||
finally
|
||||
@@ -190,6 +194,158 @@ namespace Streamingle.Editor.Tests
|
||||
"{}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DefaultCwAiRootReplacesStalePackageCacheRevision()
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"cw-ai-package-root-tests-" + Guid.NewGuid().ToString("N"));
|
||||
var project = Path.Combine(root, "UnityProject");
|
||||
var staleRuntime = Path.Combine(
|
||||
project,
|
||||
"Library",
|
||||
"PackageCache",
|
||||
"com.mingle.cw-ai@old-revision",
|
||||
"RuntimeData~");
|
||||
var currentPackage = Path.Combine(
|
||||
project,
|
||||
"Library",
|
||||
"PackageCache",
|
||||
"com.mingle.cw-ai@current-revision");
|
||||
var currentRuntime = Path.Combine(currentPackage, "RuntimeData~");
|
||||
CreateCwAiRootMarker(staleRuntime);
|
||||
CreateCwAiRootMarker(currentRuntime);
|
||||
try
|
||||
{
|
||||
Assert.That(
|
||||
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
|
||||
project,
|
||||
staleRuntime,
|
||||
currentPackage),
|
||||
Is.EqualTo(Path.GetFullPath(currentRuntime)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DefaultCwAiRootPreservesValidExternalSelection()
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"cw-ai-external-root-tests-" + Guid.NewGuid().ToString("N"));
|
||||
var project = Path.Combine(root, "UnityProject");
|
||||
var external = Path.Combine(root, "ExternalCWAI");
|
||||
var currentPackage = Path.Combine(
|
||||
project,
|
||||
"Library",
|
||||
"PackageCache",
|
||||
"com.mingle.cw-ai@current-revision");
|
||||
CreateCwAiRootMarker(external);
|
||||
CreateCwAiRootMarker(Path.Combine(currentPackage, "RuntimeData~"));
|
||||
try
|
||||
{
|
||||
Assert.That(
|
||||
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
|
||||
project,
|
||||
external,
|
||||
currentPackage),
|
||||
Is.EqualTo(Path.GetFullPath(external)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CliExecutableReplacesStalePackageCacheRevision()
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"cw-ai-worker-path-tests-" + Guid.NewGuid().ToString("N"));
|
||||
var project = Path.Combine(root, "UnityProject");
|
||||
var staleWorker = Path.Combine(
|
||||
project,
|
||||
"Library",
|
||||
"PackageCache",
|
||||
"com.mingle.cw-ai@old-revision",
|
||||
"Tools~",
|
||||
"CWCameraWorker",
|
||||
AICameraCliRunner.ExecutableName);
|
||||
var currentPackage = Path.Combine(
|
||||
project,
|
||||
"Library",
|
||||
"PackageCache",
|
||||
"com.mingle.cw-ai@current-revision");
|
||||
var currentWorker = Path.Combine(
|
||||
currentPackage,
|
||||
"Tools~",
|
||||
"CWCameraWorker",
|
||||
AICameraCliRunner.ExecutableName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(staleWorker));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(currentWorker));
|
||||
File.WriteAllText(staleWorker, string.Empty);
|
||||
File.WriteAllText(currentWorker, string.Empty);
|
||||
try
|
||||
{
|
||||
Assert.That(
|
||||
AICameraGeneratorWindow.ResolveCliExecutablePath(
|
||||
staleWorker,
|
||||
string.Empty,
|
||||
project,
|
||||
currentPackage),
|
||||
Is.EqualTo(Path.GetFullPath(currentWorker)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CliExecutablePreservesValidExternalSelection()
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"cw-ai-external-worker-tests-" + Guid.NewGuid().ToString("N"));
|
||||
var project = Path.Combine(root, "UnityProject");
|
||||
var externalWorker = Path.Combine(
|
||||
root,
|
||||
"ExternalWorker",
|
||||
AICameraCliRunner.ExecutableName);
|
||||
var currentPackage = Path.Combine(
|
||||
project,
|
||||
"Library",
|
||||
"PackageCache",
|
||||
"com.mingle.cw-ai@current-revision");
|
||||
var currentWorker = Path.Combine(
|
||||
currentPackage,
|
||||
"Tools~",
|
||||
"CWCameraWorker",
|
||||
AICameraCliRunner.ExecutableName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(externalWorker));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(currentWorker));
|
||||
File.WriteAllText(externalWorker, string.Empty);
|
||||
File.WriteAllText(currentWorker, string.Empty);
|
||||
try
|
||||
{
|
||||
Assert.That(
|
||||
AICameraGeneratorWindow.ResolveCliExecutablePath(
|
||||
externalWorker,
|
||||
string.Empty,
|
||||
project,
|
||||
currentPackage),
|
||||
Is.EqualTo(Path.GetFullPath(externalWorker)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CliWorkerIsTheOnlyHighQualityBackend()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user