fix(camera-ai): eliminate generated camera trajectory jolts
This commit is contained in:
@@ -155,6 +155,293 @@ namespace Streamingle.Editor
|
||||
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()
|
||||
{
|
||||
@@ -448,6 +735,160 @@ namespace Streamingle.Editor
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user