Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ private void CancelEmotesByMoveToWithDuration(Entity entity, ref CharacterEmoteC
[Query]
private void UpdateEmoteTags(ref CharacterEmoteComponent emoteComponent, in IAvatarView avatarView)
{
int currentStateTag = avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER);
int currentStateTag = avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER_INDEX);
emoteComponent.SetAnimationTag(currentStateTag);
}

Expand Down Expand Up @@ -188,7 +188,7 @@ private void CancelEmotes(Entity entity, ref CharacterEmoteComponent emoteCompon
return;
}

int animatorCurrentStateTag = avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER);
int animatorCurrentStateTag = avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER_INDEX);
bool isOnAnotherTag = animatorCurrentStateTag != AnimationHashes.EMOTE && animatorCurrentStateTag != AnimationHashes.EMOTE_LOOP;

// The animator left the emote tags on its own: the clip reached its natural end.
Expand Down Expand Up @@ -523,7 +523,7 @@ private void ReplicateLoopingEmotes(ref CharacterEmoteComponent animationCompone
int prevTag = animationComponent.CurrentAnimationTag;
if (prevTag == 0) return;

int currentTag = avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER);
int currentTag = avatarView.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.BASE_LAYER_INDEX);

if ((prevTag != AnimationHashes.EMOTE || currentTag != AnimationHashes.EMOTE_LOOP)
&& (prevTag != AnimationHashes.EMOTE_LOOP || currentTag != AnimationHashes.EMOTE)) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ public void StopMasked(EmoteReferences emoteReference, in IAvatarView avatarView
avatarView.ResetAnimatorTrigger(AnimationHashes.MASKED_EMOTE_REFRESH);
avatarView.SetAnimatorTrigger(AnimationHashes.MASKED_EMOTE_STOP);

string layer = AnimatorEmoteLayers.GetFromEmoteMask(mask);
int layer = avatarView.GetEmoteLayerIndex(mask);
avatarView.SetLayerWeight(layer, 0);

avatarView.ClearMaskedEmoteAnimationCache();
Expand All @@ -168,7 +168,7 @@ public bool TryCancelMaskedEmote(ref CharacterMaskedEmoteComponent masked, IAvat
shouldCancel = avatarView.HasMaskedLegacyEmoteFinished;
else if (masked.IsPlaying)
{
string layer = AnimatorEmoteLayers.GetFromEmoteMask(masked.Mask);
int layer = avatarView.GetEmoteLayerIndex(masked.Mask);
int currentTag = avatarView.GetAnimatorCurrentStateTag(layer);
shouldCancel = currentTag != AnimationHashes.MASKED_EMOTE && currentTag != AnimationHashes.MASKED_EMOTE_LOOP;
}
Expand All @@ -190,7 +190,7 @@ public static void UpdateMaskedEmoteTag(ref CharacterMaskedEmoteComponent masked
// by HasMaskedLegacyEmoteFinished on the avatar view, not by tag transitions.
if (avatarView.IsMaskedLegacyEmotePlaying || avatarView.HasMaskedLegacyEmoteFinished) return;

string layer = AnimatorEmoteLayers.GetFromEmoteMask(masked.Mask);
int layer = avatarView.GetEmoteLayerIndex(masked.Mask);
int currentStateTag = avatarView.GetAnimatorCurrentStateTag(layer);
masked.SetAnimationTag(currentStateTag);
}
Expand Down Expand Up @@ -393,7 +393,7 @@ private void PlayMaskedMecanimEmote(in IAvatarView view, ref CharacterMaskedEmot
view.ResetAnimatorTrigger(AnimationHashes.MASKED_EMOTE);
view.ResetAnimatorTrigger(AnimationHashes.MASKED_EMOTE_REFRESH);

string emoteLayer = AnimatorEmoteLayers.GetFromEmoteMask(maskedEmote.Mask);
int emoteLayer = view.GetEmoteLayerIndex(maskedEmote.Mask);
view.SetLayerWeight(emoteLayer, 1);

int targetLayerTag = view.GetAnimatorCurrentStateTag(emoteLayer);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ private void CancelMaskedEmotes(ref CharacterMaskedEmoteComponent masked)

if (!masked.IsPlaying) return;

string layer = AnimatorEmoteLayers.GetFromEmoteMask(masked.Mask);
int layer = view.GetEmoteLayerIndex(masked.Mask);
int currentTag = view.GetAnimatorCurrentStateTag(layer);
bool isOnAnotherTag = currentTag != AnimationHashes.MASKED_EMOTE && currentTag != AnimationHashes.MASKED_EMOTE_LOOP;

Expand Down Expand Up @@ -270,7 +270,7 @@ private void ReplicateLoopingMaskedEmotes(ref CharacterMaskedEmoteComponent mask
int prevTag = masked.CurrentAnimationTag;
if (prevTag == 0) return;

string layer = AnimatorEmoteLayers.GetFromEmoteMask(masked.Mask);
int layer = mainPlayerAvatarBaseProxy.Object!.GetEmoteLayerIndex(masked.Mask);
int currentTag = mainPlayerAvatarBaseProxy.Object!.GetAnimatorCurrentStateTag(layer);

if ((prevTag != AnimationHashes.MASKED_EMOTE || currentTag != AnimationHashes.MASKED_EMOTE_LOOP)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ in HandPointAtComponent handPointAtComponent
{
handsIKComponent.IsDisabled = !handsIkSystemIsEnabled;

int maskedLayerTag = avatarBase.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.UPPER_BODY_LAYER);
int maskedLayerTag = avatarBase.GetAnimatorCurrentStateTag(avatarBase.UpperBodyLayerIndex);
bool isPlayingMaskedEmote = maskedLayerTag == AnimationHashes.MASKED_EMOTE || maskedLayerTag == AnimationHashes.MASKED_EMOTE_LOOP;

// To avoid using the Hands IK during any special state we update this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ private void UpdateIK([Data] float dt,
{
// Check the upper body layer animator state to detect masked emotes.
// The component lives in scene worlds (not global), so we check the animator directly.
int maskedLayerTag = avatarBase.GetAnimatorCurrentStateTag(AnimatorEmoteLayers.UPPER_BODY_LAYER);
int maskedLayerTag = avatarBase.GetAnimatorCurrentStateTag(avatarBase.UpperBodyLayerIndex);
bool isPlayingMaskedEmote = maskedLayerTag == AnimationHashes.MASKED_EMOTE || maskedLayerTag == AnimationHashes.MASKED_EMOTE_LOOP;

bool pitchEnabled = debugHeadIKIsEnabled &&
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,10 @@
using DCL.ECSComponents;

namespace Utility.Animations
{
public static class AnimatorEmoteLayers
{
public const string BASE_LAYER = "Base Layer";
public const string UPPER_BODY_LAYER = "Upper Body Layer";

public static readonly string[] ALL_LAYERS =
{
BASE_LAYER,
UPPER_BODY_LAYER,
};
// Unity's Animator always places the base layer at index 0.
public const int BASE_LAYER_INDEX = 0;

public static readonly string[] NON_BASE_LAYERS =
{
UPPER_BODY_LAYER,
};

public static string GetFromEmoteMask(AvatarEmoteMask mask) =>
mask switch
{
AvatarEmoteMask.AemFullBody => BASE_LAYER,
AvatarEmoteMask.AemUpperBody => UPPER_BODY_LAYER,
_ => BASE_LAYER,
};
public const string UPPER_BODY_LAYER = "Upper Body Layer";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using DCL.AvatarRendering.AvatarShape.UnityInterface;
using NUnit.Framework;
using System.Diagnostics;
using System.Reflection;
using Unity.PerformanceTesting;
using Unity.Profiling;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace DCL.Tests.PlayMode.PerformanceTests
{
/// <summary>
/// Verifies SetPointAtLayerWeight / SetRotationLayerWeight skip the native Animator.SetLayerWeight write when the
/// value is unchanged from the last call, and that ResetState clears the shadowed value so the next write after
/// a rebind (e.g. pool reuse) is not skipped.
/// </summary>
[Category("Performance")]
public class AvatarLayerWeightGuardPerformanceTest
{
#if UNITY_EDITOR
private const string AVATAR_BASE_TEST_ASSET_PATH = "Assets/DCL/AvatarRendering/AvatarShape/Tests/Instantiate/TestAssets/AvatarBase_TestAsset.prefab";
private const string ANIMATOR_CONTROLLER_PATH = "Assets/DCL/AvatarRendering/AvatarShape/Assets/Animator/CharacterAnimator.controller";

private GameObject avatarGameObject = null!;
private AvatarBase avatarBase = null!;
private Animator animator = null!;
private int pointAtIndex;
private int rotationIndex;

[SetUp]
public void SetUp()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(AVATAR_BASE_TEST_ASSET_PATH);
Assert.IsNotNull(prefab, $"Could not load AvatarBase test prefab from {AVATAR_BASE_TEST_ASSET_PATH}");

avatarGameObject = Object.Instantiate(prefab);
avatarBase = avatarGameObject.GetComponentInChildren<AvatarBase>();
animator = avatarBase.AvatarAnimator;

var controller = AssetDatabase.LoadAssetAtPath<RuntimeAnimatorController>(ANIMATOR_CONTROLLER_PATH);
Assert.IsNotNull(controller, $"Could not load animator controller from {ANIMATOR_CONTROLLER_PATH}");
animator.runtimeAnimatorController = controller;

typeof(AvatarBase).GetMethod("Awake", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(avatarBase, null);

// The test prefab leaves several serialized IK references unassigned (fileID: 0). ResetState() touches
// Armature (via ResetArmatureInclination), HipsConstraint and FeetIKRig, so each would throw
// UnassignedReferenceException. Wire throwaway objects into the private serialized backing fields via
// reflection (the same technique sibling AvatarBase tests use). The rigging component types are resolved
// from the property types, so the test needs no Animation-Rigging asmref.
var rigHolder = new GameObject("test-rig-holder");
rigHolder.transform.SetParent(avatarGameObject.transform, false);

WireBackingField("Armature", rigHolder.transform);
WireBackingField("FeetIKRig", rigHolder.AddComponent(typeof(AvatarBase).GetProperty("FeetIKRig")!.PropertyType));
WireBackingField("HipsConstraint", rigHolder.AddComponent(typeof(AvatarBase).GetProperty("HipsConstraint")!.PropertyType));

pointAtIndex = animator.GetLayerIndex("RightPointAtHand");
rotationIndex = animator.GetLayerIndex("Rotation");
Assert.GreaterOrEqual(pointAtIndex, 0, "RightPointAtHand layer missing from controller");
Assert.GreaterOrEqual(rotationIndex, 0, "Rotation layer missing from controller");
}

[TearDown]
public void TearDown()
{
if (avatarGameObject != null) Object.DestroyImmediate(avatarGameObject);
}

private void WireBackingField(string propertyName, Object value) =>
typeof(AvatarBase).GetField($"<{propertyName}>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(avatarBase, value);

[Test]
[Performance]
public void RedundantLayerWeightWrites_AreElided_AndResetStateRearms()
{
avatarBase.SetPointAtLayerWeight(0.5f);
Assert.AreEqual(0.5f, animator.GetLayerWeight(pointAtIndex), 1e-4f);
avatarBase.SetPointAtLayerWeight(1f);
Assert.AreEqual(1f, animator.GetLayerWeight(pointAtIndex), 1e-4f);

avatarBase.SetRotationLayerWeight(0.25f);
Assert.AreEqual(0.25f, animator.GetLayerWeight(rotationIndex), 1e-4f);

avatarBase.SetPointAtLayerWeight(0.7f);
avatarBase.SetRotationLayerWeight(0.7f);
avatarBase.ResetState();
Assert.AreNotEqual(0.7f, animator.GetLayerWeight(pointAtIndex), "Rebind should have reset the native weight");

avatarBase.SetPointAtLayerWeight(0.7f);
Assert.AreEqual(0.7f, animator.GetLayerWeight(pointAtIndex), 1e-4f, "shadow was not re-armed by ResetState (point-at)");
avatarBase.SetRotationLayerWeight(0.7f);
Assert.AreEqual(0.7f, animator.GetLayerWeight(rotationIndex), 1e-4f, "shadow was not re-armed by ResetState (rotation)");

avatarBase.SetPointAtLayerWeight(0f);

const int ITER = 200_000;
long bestRedundant = long.MaxValue, bestAlternating = long.MaxValue;

for (int run = 0; run < 3; run++)
{
var sw = Stopwatch.StartNew();
for (int k = 0; k < ITER; k++) avatarBase.SetPointAtLayerWeight(0f);
sw.Stop();
bestRedundant = System.Math.Min(bestRedundant, sw.ElapsedTicks);

sw.Restart();
for (int k = 0; k < ITER; k++) avatarBase.SetPointAtLayerWeight(k % 2);
sw.Stop();
bestAlternating = System.Math.Min(bestAlternating, sw.ElapsedTicks);
}

Measure.Custom(new SampleGroup("RedundantWeightWrites", SampleUnit.Nanosecond), bestRedundant);
Measure.Custom(new SampleGroup("AlternatingWeightWrites", SampleUnit.Nanosecond), bestAlternating);

Assert.Less(bestRedundant, bestAlternating * 0.5, $"redundant writes ({bestRedundant}) should be far cheaper than alternating ({bestAlternating})");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Non-blocking — timing assertion stability.

The bestRedundant < bestAlternating * 0.5 threshold (≥2× speedup) should hold comfortably since guarded writes skip the native call entirely, and best-of-3 mitigates noise. If this ever flakes on loaded CI runners, widening to 0.75 would still demonstrate the optimization without weakening the test's intent.

Suggested change
Assert.Less(bestRedundant, bestAlternating * 0.5, $"redundant writes ({bestRedundant}) should be far cheaper than alternating ({bestAlternating})");
Assert.Less(bestRedundant, bestAlternating * 0.5, $"redundant writes ({bestRedundant}) should be far cheaper than alternating ({bestAlternating})");

(No change suggested — keeping current threshold is fine.)


avatarBase.SetPointAtLayerWeight(0f);
ProfilerRecorder gcAlloc = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC.Alloc");
Measure.Method(() => { for (int k = 0; k < 1000; k++) avatarBase.SetPointAtLayerWeight(0f); })
.WarmupCount(5).MeasurementCount(10).GC().Run();
long gcBytes = gcAlloc.LastValue;
gcAlloc.Dispose();
Assert.AreEqual(0, gcBytes, $"redundant weight writes must be allocation-free, allocated {gcBytes} bytes");
}
#endif
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading