BlendshapeSync RemapCurve: fix for RemapCurve in between original curve value and consistency and UI improvements#2034
Conversation
|
Hmm - I was under the impression that the current logic would handle nonlinear curves by computing the numerical derivative at the sampled point(s) and deriving the tangent accordingly. This does break with split tangents, though, as you note. But I'd be okay with limiting this to linear curves for the initial release. |
| UnityEditor.AnimationUtility.SetKeyLeftTangentMode(remapCurve, i, UnityEditor.AnimationUtility.TangentMode.Linear); | ||
| UnityEditor.AnimationUtility.SetKeyRightTangentMode(remapCurve, i, UnityEditor.AnimationUtility.TangentMode.Linear); |
There was a problem hiding this comment.
Do we need to SetKeyBroken here? Should we inherit the broken-key state of the original curve?
There was a problem hiding this comment.
SetKeyBroken(true) is Inherited from menu operation.
As far as i know, linear tangent mode will never become Broken = false. Setting curve linear will make Broken = true and any operation sets Broken = false will change both tangent mode.
Additionally, linear tangent is purely determined by sibling key so it will never be linked (non-borken).
To prevent bugs in Unity Editor or other tools, I think we should not produce state that is not possible to produce with unity editor.
There was a problem hiding this comment.
Pull request overview
This PR targets Modular Avatar’s Blendshape Sync remap-curve workflow by improving how remap curves are edited in the inspector and by refining the animation-curve remapping logic so it can better preserve curve shape (including tangent behavior) when applying a remap.
Changes:
- Introduces a custom curve attribute + property drawer to constrain the curve editor to a 0..1 domain.
- Normalizes RemapCurve in
OnValidateto enforce linear tangents and ensure keys at time 0 and 1. - Refactors remap logic into
MapCurve(...)and updates tangent scaling to use separate left/right slope estimates.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| Runtime/UI/CurveAttribute.cs.meta | Adds Unity metadata for the new runtime attribute script. |
| Runtime/UI/CurveAttribute.cs | Adds a PropertyAttribute used to pass curve editor bounds to the inspector. |
| Runtime/ModularAvatarBlendshapeSync.cs | Applies the curve attribute to RemapCurve and enforces linear/key endpoint normalization in OnValidate. |
| Editor/Inspector/InspectorCommon.cs | Adds a CustomPropertyDrawer to render curves with bounded rect input. |
| Editor/BlendshapeSyncAnimationProcessor.cs | Refactors curve remapping into MapCurve and updates tangent/value remapping behavior. |
Files not reviewed (1)
- Runtime/UI/CurveAttribute.cs.meta: Generated file
| key.inTangent *= key.inTangent > 0 ? slopeLeft : slopeRight; | ||
| key.outTangent *= key.outTangent < 0 ? slopeLeft : slopeRight; |
There was a problem hiding this comment.
incorrect. the left/right tangent refers value space mathematical left / right slope. we should use left slope for decreasing tangent and right slope for increasing tangent.
| var slopeLeft = (val - valMinus) / (t - tMinus) / 100; | ||
| var slopeRight = (valPlus - val) / (tPlus - t) / 100; | ||
| if (float.IsNaN(slopeLeft)) slopeLeft = 1; | ||
| if (float.IsNaN(slopeRight)) slopeRight = 1; |
| BlendshapeSyncUpdateLoop.Register(this); | ||
| RuntimeUtil.delayCall(Rebind); | ||
| #if UNITY_EDITOR | ||
| // limit the curve to linear curve since we cannot support non-liner curve reliably |
| internal static AnimationCurve MapCurve(AnimationCurve curve, AnimationCurve? remapCurve) | ||
| { | ||
| if (remapCurve == null || remapCurve.length < 2 || | ||
| remapCurve.length == 2 | ||
| && remapCurve[0].time == 0 && remapCurve[0].value == 0 | ||
| && remapCurve[1].time == 1 && remapCurve[1].value == 1) return curve; |
|
|
||
| namespace nadena.dev.modular_avatar.ui | ||
| { | ||
| internal class CurveAttribute : PropertyAttribute |
There was a problem hiding this comment.
@bdunderscore where should I place attributes like this and corresponding editor class? I could not find attributes in modular avatar runtime assembly so I added to UI directory since it's inspector user interface related but just Runtime/ or another directory can also be good I think.
|
I have implemented support for splitting basic bezier / hermite curve. We still need to support other tangent modes, constant curve with infinite tangent, and splitting bezier curve exactly at the place tangent become infinite (current code converts to constant curve). Refactoring and writing tests are also in progress in my local. |
|
I failed manually implementing ClampAuto mode so I have changed the way we handle tangent mode to converting curves to free curves before processing instead of manually computing tangent in our code. |
…de / RightTangentMode
anatawa12
left a comment
There was a problem hiding this comment.
I have added the tests for RemapCurve and as far as I tried it looks working well so I think this is ready for review & merge with one important spec discussion proposed.
I hope most of important decision in code are written in comment, but I'm sorry if it's too poor.
| #if MODULAR_AVATAR_FUZZ_TESTING | ||
| // fuzz testing | ||
| var random = new System.Random(Seed); | ||
| for (int i = 0; i < 100; i++) | ||
| { | ||
| var inTangent = (float)(random.NextDouble() * 3000) - 1500; | ||
| var outTangent = (float)(random.NextDouble() * 3000) - 1500; | ||
| var leftWeight = (float)random.NextDouble(); | ||
| var rightWeight = (float)random.NextDouble(); | ||
| var inValue = (float)(random.NextDouble() * 300 - 100); // -100...200 | ||
| var outValue = (float)(random.NextDouble() * 300 - 100); // -100...200 | ||
| var time = random.Next(0, 6000) / 60f; | ||
| inTangent /= time; | ||
| outTangent /= time; | ||
| var curveCase = new AnimationCurveCase( | ||
| $"Random Curve (seed: {Seed}, {i}) ({inTangent:G9}, {outTangent:G9}, {leftWeight:G9}, {rightWeight:G9}, {inValue:G9}, {outValue:G9}, {time:G9})", | ||
| new Keyframe(time: 0, value: inValue, inTangent: inTangent, outTangent: outTangent, inWeight: 0, outWeight: leftWeight), | ||
| new Keyframe(time: time, value: outValue, inTangent: outTangent, outTangent: inTangent, inWeight: rightWeight, outWeight: 0) | ||
| ); | ||
|
|
||
| yield return curveCase; | ||
| } | ||
| #endif |
There was a problem hiding this comment.
I have added fuzz testing but I found the test is flaky because of low precision value returned from AnimationCurve.Evaluate so I disabled this test by default.
(In addition 100 * 100 matrix is too huge so when we enable the test we have to reduce number of cases)
It looks the precision problem is came from t-axis bezier processing so limiting fuzz testing to hermite curve might improve flakyness though.
For example, test case TestRemapCurveIsCorrect(Random Curve (seed: 1969480910, 10),Fuzzing Curve (seed: 1969480910, 8) and time = 34.05029,
mappedCurve.Evaluate returned 32.74703 but it will be 33.9950927670292 when we compute the value with BezierSegment. about 1.248062767 of error.
animationCurve.Evaluate(time) returned 10.96229 but 10.8068297173427 with BezierSegment and results remapped valut to 34.44767 with Evaluate but 33.9950981140137.
modified test code with `BezierSegment`
[Test]
[Combinatorial]
public void TestRemapCurveIsCorrect(
[ValueSource(nameof(TestCurves))] AnimationCurveCase aniCurveCase,
[ValueSource(nameof(TestRemapCases))] AnimationCurveCase remapCase
)
{
// This tests that remap is working as expect by comparing remapped curve and remap
var animationCurve = aniCurveCase.Curve;
var remapCurve = new RemapCurve(remapCase.Curve);
var mappedCurve = BlendshapeSyncAnimationProcessor.MapCurve(animationCurve, remapCurve);
Assert.That(remapCurve.IsIdentity, Is.False);
var endTime = mappedCurve[mappedCurve.length - 1].time;
for (float time = 0; time <= endTime; time += 0.01f)
{
var original = animationCurve.Evaluate(time);
var expectValue = remapCurve.GetPointOnCurve(original).MappedValue;
var mappedValue = mappedCurve.Evaluate(time);
// we want to allow +/- 1% of error for value (in 0-100) and time (in seconds)
// 1% in value 0-100 is 1.
// 1% in time axis is 0.01 sec, and may result in error for 0.01 sec * tangent in value axis
var keyRange = Enumerable.Range(0, mappedCurve.length - 1).TakeWhile(i => mappedCurve[i].time <= time).Last();
var maxTangent = Mathf.Max(Mathf.Abs(mappedCurve[keyRange].outTangent), Mathf.Abs(mappedCurve[keyRange + 1].inTangent));
var allowedError = Mathf.Max(Mathf.Max(Mathf.Abs(expectValue) / 100, 1), maxTangent / 100);
double original2;
double expect2;
{
var keyRange1 = Enumerable.Range(0, animationCurve.length - 1).TakeWhile(i => animationCurve[i].time <= time).Last();
var startKey = animationCurve[keyRange1];
var endKey = animationCurve[keyRange1 + 1];
const float oneThird = 1.0f / 3;
var startTangentWeight = (startKey.weightedMode & WeightedMode.Out) != 0 ? startKey.outWeight : oneThird;
var endTangentWeight = (endKey.weightedMode & WeightedMode.In) != 0 ? endKey.inWeight : oneThird;
var timeSpan = endKey.time - startKey.time;
var timeAxisBezier = new BezierSegment(0, startTangentWeight, endTangentWeight, 1);
var valueAxisBezier = new BezierSegment(startKey.value,
startTangentWeight * startKey.outTangent * timeSpan,
endTangentWeight * endKey.inTangent * timeSpan,
endKey.value);
var isHermite = (startKey.weightedMode & WeightedMode.Out) == 0 && (endKey.weightedMode & WeightedMode.In) == 0;
original2 = valueAxisBezier.Compute(isHermite ? time : timeAxisBezier.Solve(Mathf.InverseLerp(startKey.time, endKey.time, time)).First(t => t is >= 0 and <= 1));
expect2 = remapCurve.GetPointOnCurve((float)original2).MappedValue;
}
double actual2;
{
var keyRange1 = Enumerable.Range(0, mappedCurve.length - 1).TakeWhile(i => mappedCurve[i].time <= time).Last();
var startKey = mappedCurve[keyRange1];
var endKey = mappedCurve[keyRange1 + 1];
const float oneThird = 1.0f / 3;
var startTangentWeight = (startKey.weightedMode & WeightedMode.Out) != 0 ? startKey.outWeight : oneThird;
var endTangentWeight = (endKey.weightedMode & WeightedMode.In) != 0 ? endKey.inWeight : oneThird;
var timeSpan = endKey.time - startKey.time;
var timeAxisBezier = new BezierSegment(0, startTangentWeight, endTangentWeight, 1);
var valueAxisBezier = new BezierSegment(startKey.value,
startTangentWeight * startKey.outTangent * timeSpan,
endTangentWeight * endKey.inTangent * timeSpan,
endKey.value);
var isHermite = (startKey.weightedMode & WeightedMode.Out) == 0 && (endKey.weightedMode & WeightedMode.In) == 0;
actual2 = valueAxisBezier.Compute(isHermite ? time : timeAxisBezier.Solve(Mathf.InverseLerp(startKey.time, endKey.time, time)).First(t => t is >= 0 and <= 1));
}
Assert.That(mappedValue, Is.EqualTo(expect2).Within(allowedError), $"at {time}, actual = {mappedValue}, actual2 = {actual2}, original = {original}, expect = {expectValue}, original computed ours = {original2}, expect with our original = {expect2}");
}
}
| /// </summary> | ||
| internal class RemapCurve | ||
| { | ||
| private const int MapScale = 100; |
There was a problem hiding this comment.
I think we should reconsider if we should define remap curve as [0,1] or [0,100].
Not all blendshapes will have [0,100] (most of them are so though) and non-VRChat platforms may supports them (by disabling clamping) and I think raw value mapping is better representation than multiplying by 100 so it might be better to express as [0, 100] curve.
For better support for non-[0,100] curve, we can disable clamping value range in editor but I think it's rare enough so clamping provides better experience. We might better to add option to change clamp value range or detect range by computing blendShape maximum / minimum frame value in the future.
Description
This PR fixes a bug that RemapCurve does not map curve as expected.
There are two sub-cases for this bug:
I think both of them is significant because both can affect merging two blendshapes into one like BreastBig/Small on body to BreastSize on outfit and splitting single BlendShapes to multiple like BreastSize to BreastBig/Small blendshape.
This PR also introduces several UI and consistency improvmeents:
Screenshots
on master and the remap curve on the left

With commit 236d0f9

With commit 1032426

Commit description